feat: improve provider readiness semantics and freshness guidance #465
@ -126,8 +126,8 @@ public function table(Table $table): Table
|
|||||||
->label('Status')
|
->label('Status')
|
||||||
->default('missing')
|
->default('missing')
|
||||||
->options([
|
->options([
|
||||||
'missing' => 'Missing',
|
'missing' => 'Needs attention',
|
||||||
'present' => 'Present',
|
'granted' => 'Granted',
|
||||||
'all' => 'All',
|
'all' => 'All',
|
||||||
]),
|
]),
|
||||||
SelectFilter::make('type')
|
SelectFilter::make('type')
|
||||||
@ -241,8 +241,10 @@ public function guidanceCase(): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(ProviderReadinessResolutionAdapter::class)
|
$guidance = app(ProviderReadinessResolutionAdapter::class)
|
||||||
->forEnvironment($tenant, ProviderReadinessResolutionAdapter::SURFACE_REQUIRED_PERMISSIONS);
|
->forEnvironment($tenant, ProviderReadinessResolutionAdapter::SURFACE_REQUIRED_PERMISSIONS);
|
||||||
|
|
||||||
|
return $this->guidanceCaseForCurrentUser($guidance, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canRunProviderVerification(): bool
|
public function canRunProviderVerification(): bool
|
||||||
@ -261,6 +263,22 @@ public function canRunProviderVerification(): bool
|
|||||||
&& $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_RUN);
|
&& $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_RUN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function canManageProviderConnection(): bool
|
||||||
|
{
|
||||||
|
$tenant = $this->trustedScopedTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $capabilityResolver */
|
||||||
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $capabilityResolver->isMember($user, $tenant)
|
||||||
|
&& $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
|
||||||
|
}
|
||||||
|
|
||||||
public function runProviderVerification(): void
|
public function runProviderVerification(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->trustedScopedTenant();
|
$tenant = $this->trustedScopedTenant();
|
||||||
@ -291,6 +309,84 @@ public function runProviderVerification(): void
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $guidance
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function guidanceCaseForCurrentUser(array $guidance, ManagedEnvironment $tenant): array
|
||||||
|
{
|
||||||
|
$canRunProviderVerification = $this->canRunProviderVerification();
|
||||||
|
$canManageProviderConnection = $this->canManageProviderConnection();
|
||||||
|
$primaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
||||||
|
$primaryActionName = is_string($primaryAction['action_name'] ?? null) ? (string) $primaryAction['action_name'] : null;
|
||||||
|
$guidanceKey = is_string($guidance['key'] ?? null) ? (string) $guidance['key'] : '';
|
||||||
|
|
||||||
|
if ($primaryActionName === 'runProviderVerification' && ! $canRunProviderVerification) {
|
||||||
|
$guidance['primary_action'] = $this->openProviderConnectionGuidanceAction($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
in_array($guidanceKey, [
|
||||||
|
'provider_readiness.admin_consent_required',
|
||||||
|
'provider_readiness.required_permissions_missing',
|
||||||
|
'provider_readiness.delegated_permissions_missing',
|
||||||
|
], true)
|
||||||
|
&& ! $canManageProviderConnection
|
||||||
|
) {
|
||||||
|
$guidance['primary_action'] = $this->openProviderConnectionGuidanceAction($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($guidance['secondary_actions'] ?? null)) {
|
||||||
|
$normalizedPrimaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
||||||
|
|
||||||
|
$guidance['secondary_actions'] = array_values(array_filter(
|
||||||
|
$guidance['secondary_actions'],
|
||||||
|
function (mixed $action) use ($canManageProviderConnection, $canRunProviderVerification, $normalizedPrimaryAction): bool {
|
||||||
|
if (! is_array($action)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actionName = is_string($action['action_name'] ?? null) ? (string) $action['action_name'] : null;
|
||||||
|
$actionLabel = is_string($action['label'] ?? null) ? Str::lower((string) $action['label']) : '';
|
||||||
|
$actionUrl = is_string($action['url'] ?? null) ? (string) $action['url'] : null;
|
||||||
|
$primaryActionUrl = is_string($normalizedPrimaryAction['url'] ?? null)
|
||||||
|
? (string) $normalizedPrimaryAction['url']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($actionUrl !== null && $primaryActionUrl !== null && $actionUrl === $primaryActionUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actionName === 'runProviderVerification') {
|
||||||
|
return $canRunProviderVerification;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($actionLabel, 'admin consent')) {
|
||||||
|
return $canManageProviderConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $guidance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function openProviderConnectionGuidanceAction(ManagedEnvironment $tenant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => __('localization.provider_guidance.action_open_provider_connection'),
|
||||||
|
'url' => ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
|
||||||
|
'action_name' => null,
|
||||||
|
'external' => false,
|
||||||
|
'disabled' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected static function resolveScopedTenant(ManagedEnvironment|string|null $tenant = null): ?ManagedEnvironment
|
protected static function resolveScopedTenant(ManagedEnvironment|string|null $tenant = null): ?ManagedEnvironment
|
||||||
{
|
{
|
||||||
if ($tenant instanceof ManagedEnvironment) {
|
if ($tenant instanceof ManagedEnvironment) {
|
||||||
@ -389,7 +485,7 @@ private function trustedScopedTenant(): ?ManagedEnvironment
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $filters
|
* @param array<string, mixed> $filters
|
||||||
* @return array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string}
|
* @return array{status:'missing'|'granted'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string}
|
||||||
*/
|
*/
|
||||||
private function filterState(array $filters = [], ?string $search = null): array
|
private function filterState(array $filters = [], ?string $search = null): array
|
||||||
{
|
{
|
||||||
@ -402,7 +498,7 @@ private function filterState(array $filters = [], ?string $search = null): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string} $state
|
* @param array{status:'missing'|'granted'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string} $state
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function viewModelForState(array $state): array
|
private function viewModelForState(array $state): array
|
||||||
@ -529,17 +625,18 @@ private function permissionsEmptyStateHeading(): string
|
|||||||
'search' => '',
|
'search' => '',
|
||||||
]), 'permissions', []);
|
]), 'permissions', []);
|
||||||
|
|
||||||
$missingTotal = (int) ($counts['missing_application'] ?? 0)
|
$attentionTotal = (int) ($counts['missing_application'] ?? 0)
|
||||||
+ (int) ($counts['missing_delegated'] ?? 0)
|
+ (int) ($counts['missing_delegated'] ?? 0)
|
||||||
+ (int) ($counts['error'] ?? 0);
|
+ (int) ($counts['blocked'] ?? 0)
|
||||||
$requiredTotal = $missingTotal + (int) ($counts['present'] ?? 0);
|
+ (int) ($counts['expired'] ?? 0)
|
||||||
|
+ (int) ($counts['unknown'] ?? 0);
|
||||||
|
|
||||||
if (! is_array($allPermissions) || $allPermissions === []) {
|
if (! is_array($allPermissions) || $allPermissions === []) {
|
||||||
return 'No permissions configured';
|
return 'No permissions configured';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($state['status'] === 'missing' && $missingTotal === 0 && $state['type'] === 'all' && $state['features'] === [] && trim($state['search']) === '') {
|
if ($state['status'] === 'missing' && $attentionTotal === 0 && $state['type'] === 'all' && $state['features'] === [] && trim($state['search']) === '') {
|
||||||
return 'All required permissions are present';
|
return 'All required permissions are granted';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'No matches';
|
return 'No matches';
|
||||||
@ -549,7 +646,7 @@ private function permissionsEmptyStateDescription(): string
|
|||||||
{
|
{
|
||||||
return match ($this->permissionsEmptyStateHeading()) {
|
return match ($this->permissionsEmptyStateHeading()) {
|
||||||
'No permissions configured' => 'No required permissions are currently configured in config/intune_permissions.php.',
|
'No permissions configured' => 'No required permissions are currently configured in config/intune_permissions.php.',
|
||||||
'All required permissions are present' => 'Switch Status to All if you want to review the full matrix.',
|
'All required permissions are granted' => 'Switch Status to All if you want to review the full matrix.',
|
||||||
default => 'No permissions match the current filters.',
|
default => 'No permissions match the current filters.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -594,8 +691,11 @@ private function statusSortWeight(string $status): int
|
|||||||
{
|
{
|
||||||
return match ($status) {
|
return match ($status) {
|
||||||
'missing' => 0,
|
'missing' => 0,
|
||||||
'error' => 1,
|
'blocked' => 1,
|
||||||
default => 2,
|
'expired' => 2,
|
||||||
|
'unknown', 'error' => 3,
|
||||||
|
'granted' => 4,
|
||||||
|
default => 5,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1512,7 +1512,10 @@ private function readinessPermissionOverview(ManagedEnvironment $tenant): array
|
|||||||
'counts' => [
|
'counts' => [
|
||||||
'missing_application' => max(0, (int) ($counts['missing_application'] ?? 0)),
|
'missing_application' => max(0, (int) ($counts['missing_application'] ?? 0)),
|
||||||
'missing_delegated' => max(0, (int) ($counts['missing_delegated'] ?? 0)),
|
'missing_delegated' => max(0, (int) ($counts['missing_delegated'] ?? 0)),
|
||||||
'present' => max(0, (int) ($counts['present'] ?? 0)),
|
'granted' => max(0, (int) ($counts['granted'] ?? 0)),
|
||||||
|
'blocked' => max(0, (int) ($counts['blocked'] ?? 0)),
|
||||||
|
'expired' => max(0, (int) ($counts['expired'] ?? 0)),
|
||||||
|
'unknown' => max(0, (int) ($counts['unknown'] ?? 0)),
|
||||||
'error' => max(0, (int) ($counts['error'] ?? 0)),
|
'error' => max(0, (int) ($counts['error'] ?? 0)),
|
||||||
],
|
],
|
||||||
'freshness' => [
|
'freshness' => [
|
||||||
@ -5357,7 +5360,7 @@ private function verificationAssistVisibility(): array
|
|||||||
* verification: array{overall:?string,status:?string,is_stale:bool,stale_reason:?string},
|
* verification: array{overall:?string,status:?string,is_stale:bool,stale_reason:?string},
|
||||||
* overview: array{
|
* overview: array{
|
||||||
* overall:string,
|
* overall:string,
|
||||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
* counts: array<string,int>,
|
||||||
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||||
* },
|
* },
|
||||||
* missing_permissions: array{
|
* missing_permissions: array{
|
||||||
|
|||||||
@ -52,6 +52,8 @@
|
|||||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewStateResolver;
|
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewStateResolver;
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessResolver;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessState;
|
||||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||||
@ -971,328 +973,328 @@ public static function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->visible(fn (ManagedEnvironment $record): bool => $record->isActive() && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
->visible(fn (ManagedEnvironment $record): bool => $record->isActive() && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Actions\Action::make('related_onboarding_overflow')
|
Actions\Action::make('related_onboarding_overflow')
|
||||||
->label(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
->label(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
||||||
->icon(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye')
|
->icon(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye')
|
||||||
->url(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (ManagedEnvironment $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
->visible(fn (ManagedEnvironment $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||||
Actions\Action::make('compareEnvironments')
|
Actions\Action::make('compareEnvironments')
|
||||||
->label('Compare tenants')
|
->label('Compare tenants')
|
||||||
->icon('heroicon-o-scale')
|
->icon('heroicon-o-scale')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(function (ManagedEnvironment $record, mixed $livewire): string {
|
->url(function (ManagedEnvironment $record, mixed $livewire): string {
|
||||||
$triageState = $livewire instanceof Pages\ListManagedEnvironments
|
$triageState = $livewire instanceof Pages\ListManagedEnvironments
|
||||||
? static::currentPortfolioTriageState($livewire)
|
? static::currentPortfolioTriageState($livewire)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (! static::hasActivePortfolioTriageState(
|
if (! static::hasActivePortfolioTriageState(
|
||||||
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||||
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||||
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||||
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||||
)) {
|
)) {
|
||||||
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::crossEnvironmentCompareOpenUrl($record, $triageState);
|
||||||
|
})
|
||||||
|
->visible(fn (ManagedEnvironment $record): bool => static::crossEnvironmentCompareActionVisible($record)),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('edit')
|
||||||
|
->label('Edit')
|
||||||
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->visible(fn (ManagedEnvironment $record): bool => static::canEdit($record))
|
||||||
|
->url(fn (ManagedEnvironment $record) => static::getUrl('edit', ['record' => $record]))
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
static::makeAdminConsentAction(),
|
||||||
|
static::makeOpenInEntraAction(),
|
||||||
|
static::makeSyncTenantAction(),
|
||||||
|
static::makeVerifyConfigurationAction(),
|
||||||
|
Actions\Action::make('markReviewed')
|
||||||
|
->label('Mark reviewed')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Mark reviewed')
|
||||||
|
->modalDescription(fn (ManagedEnvironment $record, mixed $livewire): string => static::triageReviewActionModalDescription(
|
||||||
|
$record,
|
||||||
|
static::currentPortfolioTriageState($livewire),
|
||||||
|
ManagedEnvironmentTriageReview::STATE_REVIEWED,
|
||||||
|
))
|
||||||
|
->visible(fn (ManagedEnvironment $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
|
||||||
|
$record,
|
||||||
|
static::currentPortfolioTriageState($livewire),
|
||||||
|
) !== null && static::userCanSeeTriageReviewAction($record))
|
||||||
|
->disabled(fn (ManagedEnvironment $record): bool => static::triageReviewActionIsDisabled($record))
|
||||||
|
->tooltip(fn (ManagedEnvironment $record): ?string => static::triageReviewActionTooltip($record))
|
||||||
|
->before(function (ManagedEnvironment $record): void {
|
||||||
|
static::authorizeTriageReviewAction($record);
|
||||||
|
})
|
||||||
|
->action(function (
|
||||||
|
ManagedEnvironment $record,
|
||||||
|
mixed $livewire,
|
||||||
|
ManagedEnvironmentTriageReviewService $service,
|
||||||
|
): void {
|
||||||
|
static::handleTriageReviewMutation(
|
||||||
|
tenant: $record,
|
||||||
|
triageState: static::currentPortfolioTriageState($livewire),
|
||||||
|
targetManualState: ManagedEnvironmentTriageReview::STATE_REVIEWED,
|
||||||
|
service: $service,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Actions\Action::make('markFollowUpNeeded')
|
||||||
|
->label('Mark follow-up needed')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Mark follow-up needed')
|
||||||
|
->modalDescription(fn (ManagedEnvironment $record, mixed $livewire): string => static::triageReviewActionModalDescription(
|
||||||
|
$record,
|
||||||
|
static::currentPortfolioTriageState($livewire),
|
||||||
|
ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||||
|
))
|
||||||
|
->visible(fn (ManagedEnvironment $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
|
||||||
|
$record,
|
||||||
|
static::currentPortfolioTriageState($livewire),
|
||||||
|
) !== null && static::userCanSeeTriageReviewAction($record))
|
||||||
|
->disabled(fn (ManagedEnvironment $record): bool => static::triageReviewActionIsDisabled($record))
|
||||||
|
->tooltip(fn (ManagedEnvironment $record): ?string => static::triageReviewActionTooltip($record))
|
||||||
|
->before(function (ManagedEnvironment $record): void {
|
||||||
|
static::authorizeTriageReviewAction($record);
|
||||||
|
})
|
||||||
|
->action(function (
|
||||||
|
ManagedEnvironment $record,
|
||||||
|
mixed $livewire,
|
||||||
|
ManagedEnvironmentTriageReviewService $service,
|
||||||
|
): void {
|
||||||
|
static::handleTriageReviewMutation(
|
||||||
|
tenant: $record,
|
||||||
|
triageState: static::currentPortfolioTriageState($livewire),
|
||||||
|
targetManualState: ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||||
|
service: $service,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
|
||||||
|
static::makeRestoreTenantToWorkspaceAction(),
|
||||||
|
static::rbacAction(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('forceDelete')
|
||||||
|
->label('Force delete')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (?ManagedEnvironment $record): bool => (bool) $record?->trashed())
|
||||||
|
->action(function (?ManagedEnvironment $record, AuditLogger $auditLogger): void {
|
||||||
|
if ($record === null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::crossEnvironmentCompareOpenUrl($record, $triageState);
|
$user = auth()->user();
|
||||||
})
|
|
||||||
->visible(fn (ManagedEnvironment $record): bool => static::crossEnvironmentCompareActionVisible($record)),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->icon('heroicon-o-pencil-square')
|
|
||||||
->visible(fn (ManagedEnvironment $record): bool => static::canEdit($record))
|
|
||||||
->url(fn (ManagedEnvironment $record) => static::getUrl('edit', ['record' => $record]))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
static::makeAdminConsentAction(),
|
|
||||||
static::makeOpenInEntraAction(),
|
|
||||||
static::makeSyncTenantAction(),
|
|
||||||
static::makeVerifyConfigurationAction(),
|
|
||||||
Actions\Action::make('markReviewed')
|
|
||||||
->label('Mark reviewed')
|
|
||||||
->icon('heroicon-o-check-circle')
|
|
||||||
->color('success')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Mark reviewed')
|
|
||||||
->modalDescription(fn (ManagedEnvironment $record, mixed $livewire): string => static::triageReviewActionModalDescription(
|
|
||||||
$record,
|
|
||||||
static::currentPortfolioTriageState($livewire),
|
|
||||||
ManagedEnvironmentTriageReview::STATE_REVIEWED,
|
|
||||||
))
|
|
||||||
->visible(fn (ManagedEnvironment $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
|
|
||||||
$record,
|
|
||||||
static::currentPortfolioTriageState($livewire),
|
|
||||||
) !== null && static::userCanSeeTriageReviewAction($record))
|
|
||||||
->disabled(fn (ManagedEnvironment $record): bool => static::triageReviewActionIsDisabled($record))
|
|
||||||
->tooltip(fn (ManagedEnvironment $record): ?string => static::triageReviewActionTooltip($record))
|
|
||||||
->before(function (ManagedEnvironment $record): void {
|
|
||||||
static::authorizeTriageReviewAction($record);
|
|
||||||
})
|
|
||||||
->action(function (
|
|
||||||
ManagedEnvironment $record,
|
|
||||||
mixed $livewire,
|
|
||||||
ManagedEnvironmentTriageReviewService $service,
|
|
||||||
): void {
|
|
||||||
static::handleTriageReviewMutation(
|
|
||||||
tenant: $record,
|
|
||||||
triageState: static::currentPortfolioTriageState($livewire),
|
|
||||||
targetManualState: ManagedEnvironmentTriageReview::STATE_REVIEWED,
|
|
||||||
service: $service,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
Actions\Action::make('markFollowUpNeeded')
|
|
||||||
->label('Mark follow-up needed')
|
|
||||||
->icon('heroicon-o-exclamation-triangle')
|
|
||||||
->color('warning')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Mark follow-up needed')
|
|
||||||
->modalDescription(fn (ManagedEnvironment $record, mixed $livewire): string => static::triageReviewActionModalDescription(
|
|
||||||
$record,
|
|
||||||
static::currentPortfolioTriageState($livewire),
|
|
||||||
ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
||||||
))
|
|
||||||
->visible(fn (ManagedEnvironment $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
|
|
||||||
$record,
|
|
||||||
static::currentPortfolioTriageState($livewire),
|
|
||||||
) !== null && static::userCanSeeTriageReviewAction($record))
|
|
||||||
->disabled(fn (ManagedEnvironment $record): bool => static::triageReviewActionIsDisabled($record))
|
|
||||||
->tooltip(fn (ManagedEnvironment $record): ?string => static::triageReviewActionTooltip($record))
|
|
||||||
->before(function (ManagedEnvironment $record): void {
|
|
||||||
static::authorizeTriageReviewAction($record);
|
|
||||||
})
|
|
||||||
->action(function (
|
|
||||||
ManagedEnvironment $record,
|
|
||||||
mixed $livewire,
|
|
||||||
ManagedEnvironmentTriageReviewService $service,
|
|
||||||
): void {
|
|
||||||
static::handleTriageReviewMutation(
|
|
||||||
tenant: $record,
|
|
||||||
triageState: static::currentPortfolioTriageState($livewire),
|
|
||||||
targetManualState: ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
||||||
service: $service,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
|
|
||||||
static::makeRestoreTenantToWorkspaceAction(),
|
|
||||||
static::rbacAction(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('forceDelete')
|
|
||||||
->label('Force delete')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (?ManagedEnvironment $record): bool => (bool) $record?->trashed())
|
|
||||||
->action(function (?ManagedEnvironment $record, AuditLogger $auditLogger): void {
|
|
||||||
if ($record === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
/** @var CapabilityResolver $resolver */
|
||||||
abort(403);
|
$resolver = app(CapabilityResolver::class);
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
$resolver = app(CapabilityResolver::class);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
$tenant = ManagedEnvironment::withTrashed()->find($record->id);
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = ManagedEnvironment::withTrashed()->find($record->id);
|
|
||||||
|
|
||||||
if (! $tenant?->trashed()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('ManagedEnvironment must be archived first')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'tenant.force_deleted',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['managed_environment_id' => $tenant->managed_environment_id]]
|
|
||||||
);
|
|
||||||
|
|
||||||
$tenant->forceDelete();
|
|
||||||
|
|
||||||
|
if (! $tenant?->trashed()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('ManagedEnvironment permanently deleted')
|
->title('ManagedEnvironment must be archived first')
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
static::makeRemoveTenantFromWorkspaceAction(),
|
|
||||||
static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow),
|
|
||||||
])
|
|
||||||
->label('More')
|
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
BulkActionGroup::make([
|
|
||||||
Actions\BulkAction::make('compareSelected')
|
|
||||||
->label('Compare selected')
|
|
||||||
->icon('heroicon-o-scale')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => auth()->user() instanceof User)
|
|
||||||
->authorize(fn (): bool => auth()->user() instanceof User)
|
|
||||||
->extraAttributes(fn (mixed $livewire): array => [
|
|
||||||
'x-bind:aria-disabled' => static::crossEnvironmentCompareBulkClientDisabledExpression($livewire).' ? true : null',
|
|
||||||
'x-bind:disabled' => static::crossEnvironmentCompareBulkClientDisabledExpression($livewire),
|
|
||||||
'x-bind:title' => static::crossEnvironmentCompareBulkClientTooltipExpression($livewire),
|
|
||||||
'x-bind:class' => "{ 'fi-disabled': ".static::crossEnvironmentCompareBulkClientDisabledExpression($livewire).' }',
|
|
||||||
])
|
|
||||||
->action(function (Collection $records, mixed $livewire): void {
|
|
||||||
$disabledReason = static::crossEnvironmentCompareBulkDisabledReason($records);
|
|
||||||
|
|
||||||
if ($disabledReason !== null) {
|
|
||||||
Notification::make()
|
|
||||||
->title($disabledReason)
|
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method_exists($livewire, 'redirect')) {
|
$auditLogger->log(
|
||||||
$livewire->redirect(static::crossEnvironmentCompareBulkOpenUrl($records, $livewire), navigate: true);
|
tenant: $tenant,
|
||||||
}
|
action: 'tenant.force_deleted',
|
||||||
}),
|
resourceType: 'tenant',
|
||||||
Actions\BulkAction::make('syncSelected')
|
resourceId: (string) $tenant->id,
|
||||||
->label('Sync selected')
|
status: 'success',
|
||||||
->icon('heroicon-o-arrow-path')
|
context: ['metadata' => ['managed_environment_id' => $tenant->managed_environment_id]]
|
||||||
->color('warning')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => auth()->user() instanceof User)
|
|
||||||
->authorize(fn (): bool => auth()->user() instanceof User)
|
|
||||||
->disabled(function (Collection $records): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($records->isEmpty()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return $records
|
|
||||||
->filter(fn ($record) => $record instanceof ManagedEnvironment)
|
|
||||||
->contains(fn (ManagedEnvironment $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
|
||||||
})
|
|
||||||
->tooltip(function (Collection $records): ?string {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return UiTooltips::insufficientPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($records->isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
$isDenied = $records
|
|
||||||
->filter(fn ($record) => $record instanceof ManagedEnvironment)
|
|
||||||
->contains(fn (ManagedEnvironment $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
|
||||||
|
|
||||||
return $isDenied ? UiTooltips::insufficientPermission() : null;
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
$eligible = $records
|
|
||||||
->filter(fn ($record) => $record instanceof ManagedEnvironment && $record->isActive())
|
|
||||||
->filter(fn (ManagedEnvironment $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
|
||||||
|
|
||||||
if ($eligible->isEmpty()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk sync skipped')
|
|
||||||
->body('No eligible tenants selected.')
|
|
||||||
->icon('heroicon-o-information-circle')
|
|
||||||
->info()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantContext = ManagedEnvironment::current() ?? $eligible->first();
|
|
||||||
|
|
||||||
if (! $tenantContext) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids = $eligible->pluck('id')->toArray();
|
|
||||||
$count = $eligible->count();
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
|
||||||
tenant: $tenantContext,
|
|
||||||
type: 'tenant.sync',
|
|
||||||
targetScope: [
|
|
||||||
'entra_tenant_id' => (string) ($tenantContext->managed_environment_id ?? $tenantContext->external_id),
|
|
||||||
],
|
|
||||||
selectionIdentity: $selectionIdentity,
|
|
||||||
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
|
|
||||||
BulkTenantSyncJob::dispatch(
|
|
||||||
tenantId: (int) $tenantContext->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
tenantIds: $ids,
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $user,
|
|
||||||
extraContext: [
|
|
||||||
'tenant_count' => $count,
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('tenant.sync')
|
$tenant->forceDelete();
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
Notification::make()
|
||||||
->label(OperationRunLinks::openLabel())
|
->title('ManagedEnvironment permanently deleted')
|
||||||
->url(OperationRunLinks::view($opRun, $tenantContext)),
|
->success()
|
||||||
])
|
|
||||||
->send();
|
->send();
|
||||||
})
|
}),
|
||||||
->deselectRecordsAfterCompletion(),
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
|
static::makeRemoveTenantFromWorkspaceAction(),
|
||||||
|
static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
Actions\BulkAction::make('compareSelected')
|
||||||
|
->label('Compare selected')
|
||||||
|
->icon('heroicon-o-scale')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => auth()->user() instanceof User)
|
||||||
|
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||||
|
->extraAttributes(fn (mixed $livewire): array => [
|
||||||
|
'x-bind:aria-disabled' => static::crossEnvironmentCompareBulkClientDisabledExpression($livewire).' ? true : null',
|
||||||
|
'x-bind:disabled' => static::crossEnvironmentCompareBulkClientDisabledExpression($livewire),
|
||||||
|
'x-bind:title' => static::crossEnvironmentCompareBulkClientTooltipExpression($livewire),
|
||||||
|
'x-bind:class' => "{ 'fi-disabled': ".static::crossEnvironmentCompareBulkClientDisabledExpression($livewire).' }',
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, mixed $livewire): void {
|
||||||
|
$disabledReason = static::crossEnvironmentCompareBulkDisabledReason($records);
|
||||||
|
|
||||||
|
if ($disabledReason !== null) {
|
||||||
|
Notification::make()
|
||||||
|
->title($disabledReason)
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($livewire, 'redirect')) {
|
||||||
|
$livewire->redirect(static::crossEnvironmentCompareBulkOpenUrl($records, $livewire), navigate: true);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Actions\BulkAction::make('syncSelected')
|
||||||
|
->label('Sync selected')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (): bool => auth()->user() instanceof User)
|
||||||
|
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||||
|
->disabled(function (Collection $records): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($records->isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $records
|
||||||
|
->filter(fn ($record) => $record instanceof ManagedEnvironment)
|
||||||
|
->contains(fn (ManagedEnvironment $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||||
|
})
|
||||||
|
->tooltip(function (Collection $records): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($records->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$isDenied = $records
|
||||||
|
->filter(fn ($record) => $record instanceof ManagedEnvironment)
|
||||||
|
->contains(fn (ManagedEnvironment $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||||
|
|
||||||
|
return $isDenied ? UiTooltips::insufficientPermission() : null;
|
||||||
|
})
|
||||||
|
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$eligible = $records
|
||||||
|
->filter(fn ($record) => $record instanceof ManagedEnvironment && $record->isActive())
|
||||||
|
->filter(fn (ManagedEnvironment $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||||
|
|
||||||
|
if ($eligible->isEmpty()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk sync skipped')
|
||||||
|
->body('No eligible tenants selected.')
|
||||||
|
->icon('heroicon-o-information-circle')
|
||||||
|
->info()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantContext = ManagedEnvironment::current() ?? $eligible->first();
|
||||||
|
|
||||||
|
if (! $tenantContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $eligible->pluck('id')->toArray();
|
||||||
|
$count = $eligible->count();
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenantContext,
|
||||||
|
type: 'tenant.sync',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenantContext->managed_environment_id ?? $tenantContext->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
|
||||||
|
BulkTenantSyncJob::dispatch(
|
||||||
|
tenantId: (int) $tenantContext->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
tenantIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'tenant_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('tenant.sync')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label(OperationRunLinks::openLabel())
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenantContext)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->emptyStateHeading('No tenants connected')
|
->emptyStateHeading('No tenants connected')
|
||||||
->emptyStateDescription('Add a tenant to start syncing inventory, policies, and provider health into this workspace.')
|
->emptyStateDescription('Add a tenant to start syncing inventory, policies, and provider readiness into this workspace.')
|
||||||
->emptyStateIcon('heroicon-o-building-office-2');
|
->emptyStateIcon('heroicon-o-building-office-2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2658,35 +2660,28 @@ public static function infolist(Schema $schema): Schema
|
|||||||
*/
|
*/
|
||||||
protected static function storedPermissionSnapshot(ManagedEnvironment $tenant): array
|
protected static function storedPermissionSnapshot(ManagedEnvironment $tenant): array
|
||||||
{
|
{
|
||||||
$required = config('intune_permissions.permissions', []);
|
try {
|
||||||
|
$actor = auth()->user();
|
||||||
$stored = $tenant->permissions()
|
$readiness = app(ProviderReadinessResolver::class)
|
||||||
->get()
|
->forEnvironment($tenant, $actor instanceof User ? $actor : null);
|
||||||
->keyBy('permission_key');
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
$snapshot = [];
|
|
||||||
|
|
||||||
foreach ($required as $permission) {
|
|
||||||
$key = is_string($permission['key'] ?? null) ? (string) $permission['key'] : null;
|
|
||||||
|
|
||||||
if ($key === null || $key === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$storedEntry = $stored->get($key);
|
|
||||||
$storedDetails = $storedEntry?->details;
|
|
||||||
|
|
||||||
$snapshot[] = [
|
|
||||||
'key' => $key,
|
|
||||||
'type' => is_string($permission['type'] ?? null) ? (string) $permission['type'] : 'application',
|
|
||||||
'description' => is_string($permission['description'] ?? null) ? (string) $permission['description'] : null,
|
|
||||||
'features' => is_array($permission['features'] ?? null) ? $permission['features'] : [],
|
|
||||||
'status' => is_string($storedEntry?->status ?? null) ? (string) $storedEntry->status : 'missing',
|
|
||||||
'details' => is_array($storedDetails) ? $storedDetails : null,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $snapshot;
|
return collect($readiness->permissionRows)
|
||||||
|
->filter(fn (mixed $row): bool => is_array($row) && is_string($row['key'] ?? null) && $row['key'] !== '')
|
||||||
|
->map(static fn (array $row): array => [
|
||||||
|
'key' => (string) $row['key'],
|
||||||
|
'type' => in_array(($row['type'] ?? null), ['application', 'delegated'], true)
|
||||||
|
? (string) $row['type']
|
||||||
|
: 'application',
|
||||||
|
'description' => is_string($row['description'] ?? null) ? (string) $row['description'] : null,
|
||||||
|
'features' => is_array($row['features'] ?? null) ? array_values(array_filter($row['features'], 'is_string')) : [],
|
||||||
|
'status' => is_string($row['status'] ?? null) ? (string) $row['status'] : 'unknown',
|
||||||
|
'details' => null,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation
|
protected static function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation
|
||||||
@ -3416,7 +3411,8 @@ public static function adminConsentUrl(ManagedEnvironment $tenant): ?string
|
|||||||
* display_name:?string,
|
* display_name:?string,
|
||||||
* provider:?string,
|
* provider:?string,
|
||||||
* consent_status:?string,
|
* consent_status:?string,
|
||||||
* verification_status:?string,
|
* readiness_status:?string,
|
||||||
|
* readiness_tone:?string,
|
||||||
* last_health_check_at:?string,
|
* last_health_check_at:?string,
|
||||||
* last_error_reason_code:?string,
|
* last_error_reason_code:?string,
|
||||||
* target_scope_summary:?string
|
* target_scope_summary:?string
|
||||||
@ -3451,7 +3447,8 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
|
|||||||
'lifecycle' => null,
|
'lifecycle' => null,
|
||||||
'is_enabled' => null,
|
'is_enabled' => null,
|
||||||
'consent_status' => null,
|
'consent_status' => null,
|
||||||
'verification_status' => null,
|
'readiness_status' => null,
|
||||||
|
'readiness_tone' => null,
|
||||||
'last_health_check_at' => null,
|
'last_health_check_at' => null,
|
||||||
'last_error_reason_code' => null,
|
'last_error_reason_code' => null,
|
||||||
'target_scope_summary' => null,
|
'target_scope_summary' => null,
|
||||||
@ -3464,6 +3461,12 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
|
|||||||
$targetScopeSummary = 'Target scope needs review';
|
$targetScopeSummary = 'Target scope needs review';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
$readiness = app(ProviderReadinessResolver::class)->forConnection(
|
||||||
|
$connection,
|
||||||
|
$actor instanceof User ? $actor : null,
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'state' => $connection->is_default ? 'default_configured' : 'configured',
|
'state' => $connection->is_default ? 'default_configured' : 'configured',
|
||||||
'cta_url' => $ctaUrl,
|
'cta_url' => $ctaUrl,
|
||||||
@ -3475,15 +3478,27 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
|
|||||||
'consent_status' => $connection->consent_status instanceof BackedEnum
|
'consent_status' => $connection->consent_status instanceof BackedEnum
|
||||||
? (string) $connection->consent_status->value
|
? (string) $connection->consent_status->value
|
||||||
: (is_string($connection->consent_status) ? $connection->consent_status : null),
|
: (is_string($connection->consent_status) ? $connection->consent_status : null),
|
||||||
'verification_status' => $connection->verification_status instanceof BackedEnum
|
'readiness_status' => $readiness->state->label(),
|
||||||
? (string) $connection->verification_status->value
|
'readiness_tone' => self::providerReadinessTone($readiness->state),
|
||||||
: (is_string($connection->verification_status) ? $connection->verification_status : null),
|
|
||||||
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),
|
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),
|
||||||
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
|
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
|
||||||
'target_scope_summary' => $targetScopeSummary,
|
'target_scope_summary' => $targetScopeSummary,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function providerReadinessTone(ProviderReadinessState $state): string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
ProviderReadinessState::Ready => 'success',
|
||||||
|
ProviderReadinessState::Blocked,
|
||||||
|
ProviderReadinessState::Failed => 'danger',
|
||||||
|
ProviderReadinessState::NeedsAttention,
|
||||||
|
ProviderReadinessState::Expired,
|
||||||
|
ProviderReadinessState::Unknown => 'warning',
|
||||||
|
ProviderReadinessState::NotConfigured => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static function entraUrl(ManagedEnvironment $tenant): ?string
|
public static function entraUrl(ManagedEnvironment $tenant): ?string
|
||||||
{
|
{
|
||||||
$connection = ProviderConnection::query()
|
$connection = ProviderConnection::query()
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\ManagedEnvironmentLinks;
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||||
@ -30,10 +31,12 @@
|
|||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
use App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter;
|
use App\Support\Providers\Readiness\ProviderReadinessResolver;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessState;
|
||||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -538,9 +541,62 @@ private static function lifecycleLabelFromState(mixed $state): string
|
|||||||
return BadgeRenderer::spec(BadgeDomain::BooleanEnabled, $state)->label;
|
return BadgeRenderer::spec(BadgeDomain::BooleanEnabled, $state)->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function verificationStatusLabelFromState(mixed $state): string
|
private static function providerReadinessLabel(?ProviderConnection $record): string
|
||||||
{
|
{
|
||||||
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
|
return static::providerReadinessState($record)->label();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function providerReadinessState(?ProviderConnection $record): ProviderReadinessState
|
||||||
|
{
|
||||||
|
if (! $record instanceof ProviderConnection) {
|
||||||
|
return ProviderReadinessState::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$actor = auth()->user();
|
||||||
|
|
||||||
|
return app(ProviderReadinessResolver::class)
|
||||||
|
->forConnection($record, $actor instanceof User ? $actor : null)
|
||||||
|
->state;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return ProviderReadinessState::Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function providerReadinessStateValue(?ProviderConnection $record): string
|
||||||
|
{
|
||||||
|
return static::providerReadinessState($record)->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function providerReadinessLabelFromState(mixed $state): string
|
||||||
|
{
|
||||||
|
return (ProviderReadinessState::tryFrom((string) $state) ?? ProviderReadinessState::Unknown)->label();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function providerReadinessColorFromState(mixed $state): string
|
||||||
|
{
|
||||||
|
return match (ProviderReadinessState::tryFrom((string) $state) ?? ProviderReadinessState::Unknown) {
|
||||||
|
ProviderReadinessState::Ready => 'success',
|
||||||
|
ProviderReadinessState::Blocked,
|
||||||
|
ProviderReadinessState::Failed => 'danger',
|
||||||
|
ProviderReadinessState::NeedsAttention,
|
||||||
|
ProviderReadinessState::Expired,
|
||||||
|
ProviderReadinessState::Unknown => 'warning',
|
||||||
|
ProviderReadinessState::NotConfigured => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function providerReadinessIconFromState(mixed $state): string
|
||||||
|
{
|
||||||
|
return match (ProviderReadinessState::tryFrom((string) $state) ?? ProviderReadinessState::Unknown) {
|
||||||
|
ProviderReadinessState::Ready => 'heroicon-m-check-circle',
|
||||||
|
ProviderReadinessState::Blocked,
|
||||||
|
ProviderReadinessState::Failed => 'heroicon-m-x-circle',
|
||||||
|
ProviderReadinessState::NeedsAttention => 'heroicon-m-exclamation-triangle',
|
||||||
|
ProviderReadinessState::Expired => 'heroicon-m-clock',
|
||||||
|
ProviderReadinessState::Unknown => 'heroicon-m-question-mark-circle',
|
||||||
|
ProviderReadinessState::NotConfigured => 'heroicon-m-minus-circle',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function targetScopeHelpText(): string
|
private static function targetScopeHelpText(): string
|
||||||
@ -653,8 +709,86 @@ public static function providerReadinessGuidance(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(ProviderReadinessResolutionAdapter::class)
|
$guidance = app(ProviderReadinessResolutionAdapter::class)
|
||||||
->forConnection($tenant, $record, $surface);
|
->forConnection($tenant, $record, $surface);
|
||||||
|
|
||||||
|
return static::providerReadinessGuidanceForCurrentUser($guidance, $tenant, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $guidance
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function providerReadinessGuidanceForCurrentUser(
|
||||||
|
array $guidance,
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $record,
|
||||||
|
): array {
|
||||||
|
$primaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
||||||
|
$primaryActionName = is_string($primaryAction['action_name'] ?? null) ? (string) $primaryAction['action_name'] : null;
|
||||||
|
$guidanceKey = is_string($guidance['key'] ?? null) ? (string) $guidance['key'] : '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
$primaryActionName === 'runProviderVerification'
|
||||||
|
&& ! static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_RUN, requiresEnabled: true)
|
||||||
|
) {
|
||||||
|
$guidance['primary_action'] = static::openRequiredPermissionsGuidanceAction($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
in_array($guidanceKey, [
|
||||||
|
'provider_readiness.admin_consent_required',
|
||||||
|
'provider_readiness.connection_review_required',
|
||||||
|
], true)
|
||||||
|
&& ! static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE)
|
||||||
|
) {
|
||||||
|
$guidance['primary_action'] = static::openRequiredPermissionsGuidanceAction($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($guidance['secondary_actions'] ?? null)) {
|
||||||
|
$normalizedPrimaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
||||||
|
|
||||||
|
$guidance['secondary_actions'] = array_values(array_filter(
|
||||||
|
$guidance['secondary_actions'],
|
||||||
|
static function (mixed $action) use ($record, $normalizedPrimaryAction): bool {
|
||||||
|
if (! is_array($action)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actionName = is_string($action['action_name'] ?? null) ? (string) $action['action_name'] : null;
|
||||||
|
$actionUrl = is_string($action['url'] ?? null) ? (string) $action['url'] : null;
|
||||||
|
$primaryActionUrl = is_string($normalizedPrimaryAction['url'] ?? null)
|
||||||
|
? (string) $normalizedPrimaryAction['url']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($actionUrl !== null && $primaryActionUrl !== null && $actionUrl === $primaryActionUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actionName !== 'runProviderVerification') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_RUN, requiresEnabled: true);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $guidance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function openRequiredPermissionsGuidanceAction(ManagedEnvironment $tenant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => __('localization.provider_guidance.action_open_required_permissions'),
|
||||||
|
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||||
|
'action_name' => null,
|
||||||
|
'external' => false,
|
||||||
|
'disabled' => false,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -733,8 +867,8 @@ public static function form(Schema $schema): Schema
|
|||||||
->label('Consent')
|
->label('Consent')
|
||||||
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
|
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
|
||||||
Placeholder::make('verification_status_display')
|
Placeholder::make('verification_status_display')
|
||||||
->label('Verification')
|
->label('Readiness')
|
||||||
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
|
->content(fn (?ProviderConnection $record): string => static::providerReadinessLabel($record)),
|
||||||
Placeholder::make('provider_capability_display')
|
Placeholder::make('provider_capability_display')
|
||||||
->label('Provider capability')
|
->label('Provider capability')
|
||||||
->content(fn (?ProviderConnection $record): string => static::providerCapabilitySummary($record)),
|
->content(fn (?ProviderConnection $record): string => static::providerCapabilitySummary($record)),
|
||||||
@ -820,12 +954,13 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
|
||||||
Infolists\Components\TextEntry::make('verification_status')
|
Infolists\Components\TextEntry::make('verification_status')
|
||||||
->label('Verification')
|
->label('Readiness')
|
||||||
|
->state(fn (ProviderConnection $record): string => static::providerReadinessStateValue($record))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state))
|
->formatStateUsing(fn ($state): string => static::providerReadinessLabelFromState($state))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
->color(fn ($state): string => static::providerReadinessColorFromState($state))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
->icon(fn ($state): string => static::providerReadinessIconFromState($state))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
->iconColor(fn ($state): string => static::providerReadinessColorFromState($state)),
|
||||||
Infolists\Components\TextEntry::make('provider_capability')
|
Infolists\Components\TextEntry::make('provider_capability')
|
||||||
->label('Provider capability')
|
->label('Provider capability')
|
||||||
->state(fn (ProviderConnection $record): string => static::providerCapabilityStatus($record))
|
->state(fn (ProviderConnection $record): string => static::providerCapabilityStatus($record))
|
||||||
@ -948,12 +1083,13 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
|
||||||
Tables\Columns\TextColumn::make('verification_status')
|
Tables\Columns\TextColumn::make('verification_status')
|
||||||
->label('Verification')
|
->label('Readiness')
|
||||||
|
->state(fn (ProviderConnection $record): string => static::providerReadinessStateValue($record))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderVerificationStatus))
|
->formatStateUsing(fn ($state): string => static::providerReadinessLabelFromState($state))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
->color(fn ($state): string => static::providerReadinessColorFromState($state))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
->icon(fn ($state): string => static::providerReadinessIconFromState($state))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
->iconColor(fn ($state): string => static::providerReadinessColorFromState($state)),
|
||||||
Tables\Columns\TextColumn::make('provider_capability')
|
Tables\Columns\TextColumn::make('provider_capability')
|
||||||
->label('Provider capability')
|
->label('Provider capability')
|
||||||
->state(fn (ProviderConnection $record): string => static::providerCapabilityStatus($record))
|
->state(fn (ProviderConnection $record): string => static::providerCapabilityStatus($record))
|
||||||
@ -1032,11 +1168,11 @@ public static function table(Table $table): Table
|
|||||||
return $query->where('provider_connections.consent_status', $value);
|
return $query->where('provider_connections.consent_status', $value);
|
||||||
}),
|
}),
|
||||||
SelectFilter::make('verification_status')
|
SelectFilter::make('verification_status')
|
||||||
->label('Verification')
|
->label('Last verification result')
|
||||||
->options([
|
->options([
|
||||||
'unknown' => 'Unknown',
|
'unknown' => 'Unknown',
|
||||||
'pending' => 'Pending',
|
'pending' => 'Pending',
|
||||||
'healthy' => 'Healthy',
|
'healthy' => 'Last check passed',
|
||||||
'degraded' => 'Degraded',
|
'degraded' => 'Degraded',
|
||||||
'blocked' => 'Blocked',
|
'blocked' => 'Blocked',
|
||||||
'error' => 'Error',
|
'error' => 'Error',
|
||||||
@ -1099,8 +1235,10 @@ public static function makeEditNavigationAction(): Actions\Action
|
|||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
->icon('heroicon-o-pencil-square')
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE))
|
||||||
->url(fn (ProviderConnection $record): string => static::getUrl('edit', ['record' => $record], tenant: static::resolveTenantForRecord($record)))
|
->url(fn (ProviderConnection $record): string => static::getUrl('edit', ['record' => $record], tenant: static::resolveTenantForRecord($record)))
|
||||||
)
|
)
|
||||||
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
@ -1192,7 +1330,9 @@ public static function makeSetDefaultAction(): Actions\Action
|
|||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled && ! $record->is_default)
|
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled
|
||||||
|
&& ! $record->is_default
|
||||||
|
&& static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE))
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = static::resolveTenantForRecord($record);
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
@ -1239,7 +1379,8 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
|
|||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type !== ProviderConnectionType::Dedicated)
|
->visible(fn (ProviderConnection $record): bool => $record->connection_type !== ProviderConnectionType::Dedicated
|
||||||
|
&& static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE_DEDICATED))
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Dedicated app (client) ID')
|
->label('Dedicated app (client) ID')
|
||||||
@ -1311,7 +1452,8 @@ public static function makeRotateDedicatedCredentialAction(?string $modalDescrip
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
|
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated
|
||||||
|
&& static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE_DEDICATED))
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Dedicated app (client) ID')
|
->label('Dedicated app (client) ID')
|
||||||
@ -1365,7 +1507,8 @@ public static function makeDeleteDedicatedCredentialAction(?string $modalDescrip
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated
|
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated
|
||||||
&& $record->credential()->exists())
|
&& $record->credential()->exists()
|
||||||
|
&& static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE_DEDICATED))
|
||||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||||
$tenant = static::resolveTenantForRecord($record);
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
@ -1398,7 +1541,8 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
|
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated
|
||||||
|
&& static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE_DEDICATED))
|
||||||
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger) use ($source): void {
|
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger) use ($source): void {
|
||||||
$tenant = static::resolveTenantForRecord($record);
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
@ -1455,7 +1599,8 @@ public static function makeEnableConnectionAction(): Actions\Action
|
|||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled)
|
->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled
|
||||||
|
&& static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE))
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = static::resolveTenantForRecord($record);
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
@ -1530,7 +1675,8 @@ public static function makeDisableConnectionAction(): Actions\Action
|
|||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled
|
||||||
|
&& static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_MANAGE))
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = static::resolveTenantForRecord($record);
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
@ -1578,14 +1724,31 @@ public static function makeDisableConnectionAction(): Actions\Action
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static function recordAllowsProviderExecution(ProviderConnection $record): bool
|
private static function recordAllowsProviderExecution(ProviderConnection $record): bool
|
||||||
|
{
|
||||||
|
return static::recordAllowsProviderCapability($record, Capabilities::PROVIDER_RUN, requiresEnabled: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function recordAllowsProviderCapability(
|
||||||
|
ProviderConnection $record,
|
||||||
|
string $capability,
|
||||||
|
bool $requiresEnabled = false,
|
||||||
|
): bool
|
||||||
{
|
{
|
||||||
$tenant = static::resolveTenantForRecord($record);
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return $tenant instanceof ManagedEnvironment
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||||
&& $user instanceof User
|
return false;
|
||||||
&& $user->canAccessTenant($tenant)
|
}
|
||||||
&& (bool) $record->is_enabled;
|
|
||||||
|
if ($requiresEnabled && ! (bool) $record->is_enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, $capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function handleCheckConnectionAction(ProviderConnection $record, StartVerification $verification, mixed $livewire = null): void
|
private static function handleCheckConnectionAction(ProviderConnection $record, StartVerification $verification, mixed $livewire = null): void
|
||||||
|
|||||||
@ -33,7 +33,7 @@ protected function getStats(): array
|
|||||||
->description(sprintf('Operational stability, review-pack readiness, and engagement freshness honor %s.', $windowLabel))
|
->description(sprintf('Operational stability, review-pack readiness, and engagement freshness honor %s.', $windowLabel))
|
||||||
->color($counts['ok'] > 0 ? 'success' : 'gray'),
|
->color($counts['ok'] > 0 ? 'success' : 'gray'),
|
||||||
Stat::make('Warning', $counts['warn'])
|
Stat::make('Warning', $counts['warn'])
|
||||||
->description('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
|
->description('Onboarding readiness, provider readiness, and governance pressure stay point-in-time.')
|
||||||
->color($counts['warn'] > 0 ? 'warning' : 'gray'),
|
->color($counts['warn'] > 0 ? 'warning' : 'gray'),
|
||||||
Stat::make('Critical', $counts['critical'])
|
Stat::make('Critical', $counts['critical'])
|
||||||
->description('Overall workspace health is derived from existing system truth only.')
|
->description('Overall workspace health is derived from existing system truth only.')
|
||||||
|
|||||||
@ -28,7 +28,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
$debugbar = app()->bound('debugbar') ? app('debugbar') : null;
|
$debugbar = app()->bound('debugbar') ? app('debugbar') : null;
|
||||||
|
|
||||||
config(['debugbar.enabled' => false]);
|
config([
|
||||||
|
'debugbar.enabled' => false,
|
||||||
|
'debugbar.inject' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($debugbar instanceof LaravelDebugbar && $debugbar->isEnabled()) {
|
if ($debugbar instanceof LaravelDebugbar && $debugbar->isEnabled()) {
|
||||||
$debugbar->disable();
|
$debugbar->disable();
|
||||||
@ -39,6 +42,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
private function shouldSuppressDebugbar(Request $request): bool
|
private function shouldSuppressDebugbar(Request $request): bool
|
||||||
{
|
{
|
||||||
|
if ($request->is('admin/local/smoke-login', 'admin/local/backup-health-browser-fixture-login')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if ($request->cookie(self::COOKIE_NAME) === self::COOKIE_VALUE) {
|
if ($request->cookie(self::COOKIE_NAME) === self::COOKIE_VALUE) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\ManagedEnvironment;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Intune\AuditLogger as TenantAuditLogger;
|
use App\Services\Intune\AuditLogger as TenantAuditLogger;
|
||||||
@ -125,6 +125,7 @@ public function handle(
|
|||||||
liveCheck: true,
|
liveCheck: true,
|
||||||
useConfiguredStub: false,
|
useConfiguredStub: false,
|
||||||
graphOptions: $graphOptions,
|
graphOptions: $graphOptions,
|
||||||
|
providerConnection: $connection,
|
||||||
))
|
))
|
||||||
: $permissionService->compare(
|
: $permissionService->compare(
|
||||||
$tenant,
|
$tenant,
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\ManagedEnvironmentPermission;
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
@ -32,8 +33,14 @@ public function getRequiredPermissions(): array
|
|||||||
*/
|
*/
|
||||||
public function getGrantedPermissions(ManagedEnvironment $tenant): array
|
public function getGrantedPermissions(ManagedEnvironment $tenant): array
|
||||||
{
|
{
|
||||||
return ManagedEnvironmentPermission::query()
|
$query = ManagedEnvironmentPermission::query()
|
||||||
->where('managed_environment_id', $tenant->id)
|
->where('managed_environment_id', $tenant->id)
|
||||||
|
->when(
|
||||||
|
is_numeric($tenant->workspace_id),
|
||||||
|
fn ($query) => $query->where('workspace_id', (int) $tenant->workspace_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $query
|
||||||
->get()
|
->get()
|
||||||
->keyBy('permission_key')
|
->keyBy('permission_key')
|
||||||
->map(fn (ManagedEnvironmentPermission $permission) => [
|
->map(fn (ManagedEnvironmentPermission $permission) => [
|
||||||
@ -64,6 +71,7 @@ public function compare(
|
|||||||
bool $liveCheck = false,
|
bool $liveCheck = false,
|
||||||
bool $useConfiguredStub = true,
|
bool $useConfiguredStub = true,
|
||||||
?array $graphOptions = null,
|
?array $graphOptions = null,
|
||||||
|
?ProviderConnection $providerConnection = null,
|
||||||
): array {
|
): array {
|
||||||
$required = $this->getRequiredPermissions();
|
$required = $this->getRequiredPermissions();
|
||||||
$liveCheckMeta = [
|
$liveCheckMeta = [
|
||||||
@ -146,6 +154,12 @@ public function compare(
|
|||||||
$hasErrors = false;
|
$hasErrors = false;
|
||||||
$checkedAt = now();
|
$checkedAt = now();
|
||||||
$tenantWorkspaceId = $this->resolveTenantWorkspaceId($tenant);
|
$tenantWorkspaceId = $this->resolveTenantWorkspaceId($tenant);
|
||||||
|
$providerEvidenceDetails = $this->providerEvidenceDetails(
|
||||||
|
tenant: $tenant,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
graphOptions: $graphOptions,
|
||||||
|
checkedAt: $checkedAt,
|
||||||
|
);
|
||||||
|
|
||||||
$canPersist = $persist && $tenantWorkspaceId !== null;
|
$canPersist = $persist && $tenantWorkspaceId !== null;
|
||||||
|
|
||||||
@ -188,6 +202,14 @@ public function compare(
|
|||||||
], fn (mixed $value): bool => $value !== null)
|
], fn (mixed $value): bool => $value !== null)
|
||||||
: ($granted[$key]['details'] ?? null);
|
: ($granted[$key]['details'] ?? null);
|
||||||
|
|
||||||
|
if ($providerEvidenceDetails !== []) {
|
||||||
|
$details = array_replace(
|
||||||
|
['source' => 'graph_api'],
|
||||||
|
is_array($details) ? $details : [],
|
||||||
|
$providerEvidenceDetails,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($canPersist) {
|
if ($canPersist) {
|
||||||
ManagedEnvironmentPermission::updateOrCreate(
|
ManagedEnvironmentPermission::updateOrCreate(
|
||||||
[
|
[
|
||||||
@ -433,8 +455,14 @@ private function fetchLivePermissions(ManagedEnvironment $tenant, ?array $graphO
|
|||||||
|
|
||||||
private function lastRefreshedAtIso(ManagedEnvironment $tenant): ?string
|
private function lastRefreshedAtIso(ManagedEnvironment $tenant): ?string
|
||||||
{
|
{
|
||||||
$lastCheckedAt = ManagedEnvironmentPermission::query()
|
$query = ManagedEnvironmentPermission::query()
|
||||||
->where('managed_environment_id', (int) $tenant->getKey())
|
->where('managed_environment_id', (int) $tenant->getKey());
|
||||||
|
|
||||||
|
if (is_numeric($tenant->workspace_id)) {
|
||||||
|
$query->where('workspace_id', (int) $tenant->workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastCheckedAt = $query
|
||||||
->max('last_checked_at');
|
->max('last_checked_at');
|
||||||
|
|
||||||
if ($lastCheckedAt instanceof DateTimeInterface) {
|
if ($lastCheckedAt instanceof DateTimeInterface) {
|
||||||
@ -451,4 +479,36 @@ private function lastRefreshedAtIso(ManagedEnvironment $tenant): ?string
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{tenant?:string|null,client_id?:string|null,client_secret?:string|null,client_request_id?:string|null}|null $graphOptions
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function providerEvidenceDetails(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
?ProviderConnection $providerConnection,
|
||||||
|
?array $graphOptions,
|
||||||
|
Carbon $checkedAt,
|
||||||
|
): array {
|
||||||
|
if (! $providerConnection instanceof ProviderConnection) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = [
|
||||||
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => (string) $providerConnection->provider,
|
||||||
|
'checked_at' => $checkedAt->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_numeric($tenant->workspace_id)) {
|
||||||
|
$details['workspace_id'] = (int) $tenant->workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($graphOptions['client_id'] ?? null) && $graphOptions['client_id'] !== '') {
|
||||||
|
$details['app_id'] = (string) $graphOptions['client_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $details;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,11 @@
|
|||||||
namespace App\Services\Intune;
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\User;
|
||||||
use App\Support\Providers\Capabilities\ProviderCapabilityDefinition;
|
use App\Support\Providers\Capabilities\ProviderCapabilityDefinition;
|
||||||
use App\Support\Providers\Capabilities\ProviderCapabilityRegistry;
|
use App\Support\Providers\Capabilities\ProviderCapabilityRegistry;
|
||||||
use App\Support\Providers\Capabilities\ProviderCapabilityStatus;
|
use App\Support\Providers\Capabilities\ProviderCapabilityStatus;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessResolver;
|
||||||
use App\Support\Verification\ManagedEnvironmentPermissionCheckClusters;
|
use App\Support\Verification\ManagedEnvironmentPermissionCheckClusters;
|
||||||
use App\Support\Verification\VerificationReportOverall;
|
use App\Support\Verification\VerificationReportOverall;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
@ -14,15 +16,15 @@
|
|||||||
class ManagedEnvironmentRequiredPermissionsViewModelBuilder
|
class ManagedEnvironmentRequiredPermissionsViewModelBuilder
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @phpstan-type ManagedEnvironmentPermissionRow array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}
|
* @phpstan-type ManagedEnvironmentPermissionRow array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'blocked'|'expired'|'unknown'|'not_applicable',details:array<string,mixed>|null}
|
||||||
* @phpstan-type FeatureImpact array{feature:string,missing:int,required_application:int,required_delegated:int,blocked:bool}
|
* @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<int,string>,missing_requirement_keys:array<int,string>,evidence_counts:array{requirements:int,missing:int,errors:int},message:string}
|
* @phpstan-type CapabilityGroup array{provider_capability_key:string,label:string,status:string,provider_requirement_keys:array<int,string>,missing_requirement_keys:array<int,string>,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<int,string>,search:string}
|
* @phpstan-type FilterState array{status:'missing'|'granted'|'all',type:'application'|'delegated'|'all',features:array<int,string>,search:string}
|
||||||
* @phpstan-type ViewModel array{
|
* @phpstan-type ViewModel array{
|
||||||
* tenant: array{id:int,external_id:string,name:string},
|
* tenant: array{id:int,external_id:string,name:string},
|
||||||
* overview: array{
|
* overview: array{
|
||||||
* overall: string,
|
* overall: string,
|
||||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
* counts: array<string,int>,
|
||||||
* feature_impacts: array<int, FeatureImpact>,
|
* feature_impacts: array<int, FeatureImpact>,
|
||||||
* capability_groups: array<int, CapabilityGroup>,
|
* capability_groups: array<int, CapabilityGroup>,
|
||||||
* primary_capability_group: CapabilityGroup|null,
|
* primary_capability_group: CapabilityGroup|null,
|
||||||
@ -41,12 +43,7 @@ public function __construct(private readonly ManagedEnvironmentPermissionService
|
|||||||
*/
|
*/
|
||||||
public function build(ManagedEnvironment $tenant, array $filters = []): array
|
public function build(ManagedEnvironment $tenant, array $filters = []): array
|
||||||
{
|
{
|
||||||
$comparison = $this->permissionService->compare(
|
$comparison = $this->readinessComparison($tenant);
|
||||||
$tenant,
|
|
||||||
persist: false,
|
|
||||||
liveCheck: false,
|
|
||||||
useConfiguredStub: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
/** @var array<int, ManagedEnvironmentPermissionRow> $allPermissions */
|
/** @var array<int, ManagedEnvironmentPermissionRow> $allPermissions */
|
||||||
$allPermissions = collect($comparison['permissions'] ?? [])
|
$allPermissions = collect($comparison['permissions'] ?? [])
|
||||||
@ -58,9 +55,14 @@ public function build(ManagedEnvironment $tenant, array $filters = []): array
|
|||||||
$state = self::normalizeFilterState($filters);
|
$state = self::normalizeFilterState($filters);
|
||||||
|
|
||||||
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
||||||
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
|
$freshness = is_array($comparison['freshness'] ?? null)
|
||||||
|
? array_replace([
|
||||||
|
'last_refreshed_at' => null,
|
||||||
|
'is_stale' => true,
|
||||||
|
], $comparison['freshness'])
|
||||||
|
: self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
|
||||||
|
|
||||||
$summaryPermissions = $filteredPermissions;
|
$summaryPermissions = $allPermissions;
|
||||||
$capabilityGroups = self::deriveCapabilityGroups($allPermissions, $freshness);
|
$capabilityGroups = self::deriveCapabilityGroups($allPermissions, $freshness);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -146,15 +148,23 @@ private static function deriveCapabilityGroup(
|
|||||||
$rows,
|
$rows,
|
||||||
static fn (array $row): bool => ($row['status'] ?? null) === 'missing',
|
static fn (array $row): bool => ($row['status'] ?? null) === 'missing',
|
||||||
));
|
));
|
||||||
$errorRows = array_values(array_filter(
|
$blockedRows = array_values(array_filter(
|
||||||
$rows,
|
$rows,
|
||||||
static fn (array $row): bool => ($row['status'] ?? null) === 'error',
|
static fn (array $row): bool => ($row['status'] ?? null) === 'blocked',
|
||||||
|
));
|
||||||
|
$expiredRows = array_values(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (array $row): bool => ($row['status'] ?? null) === 'expired',
|
||||||
|
));
|
||||||
|
$unknownRows = array_values(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (array $row): bool => ($row['status'] ?? null) === 'unknown',
|
||||||
));
|
));
|
||||||
$missingRequirementKeys = [];
|
$missingRequirementKeys = [];
|
||||||
|
|
||||||
foreach ($rowsByRequirement as $requirementKey => $requirementRows) {
|
foreach ($rowsByRequirement as $requirementKey => $requirementRows) {
|
||||||
foreach ($requirementRows as $row) {
|
foreach ($requirementRows as $row) {
|
||||||
if (in_array(($row['status'] ?? null), ['missing', 'error'], true)) {
|
if (in_array(($row['status'] ?? null), ['missing', 'blocked', 'expired', 'unknown'], true)) {
|
||||||
$missingRequirementKeys[] = (string) $requirementKey;
|
$missingRequirementKeys[] = (string) $requirementKey;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -163,8 +173,9 @@ private static function deriveCapabilityGroup(
|
|||||||
|
|
||||||
$status = match (true) {
|
$status = match (true) {
|
||||||
$rows === [] => ProviderCapabilityStatus::NotApplicable,
|
$rows === [] => ProviderCapabilityStatus::NotApplicable,
|
||||||
$errorRows !== [] => ProviderCapabilityStatus::Unknown,
|
$blockedRows !== [] => ProviderCapabilityStatus::Blocked,
|
||||||
$missingRows !== [] => ProviderCapabilityStatus::Missing,
|
$missingRows !== [] => ProviderCapabilityStatus::Missing,
|
||||||
|
$expiredRows !== [] || $unknownRows !== [] => ProviderCapabilityStatus::Unknown,
|
||||||
$isStale => ProviderCapabilityStatus::Unknown,
|
$isStale => ProviderCapabilityStatus::Unknown,
|
||||||
default => ProviderCapabilityStatus::Supported,
|
default => ProviderCapabilityStatus::Supported,
|
||||||
};
|
};
|
||||||
@ -186,7 +197,10 @@ private static function deriveCapabilityGroup(
|
|||||||
'evidence_counts' => [
|
'evidence_counts' => [
|
||||||
'requirements' => count($rows),
|
'requirements' => count($rows),
|
||||||
'missing' => count($missingRows),
|
'missing' => count($missingRows),
|
||||||
'errors' => count($errorRows),
|
'errors' => 0,
|
||||||
|
'blocked' => count($blockedRows),
|
||||||
|
'expired' => count($expiredRows),
|
||||||
|
'unknown' => count($unknownRows),
|
||||||
],
|
],
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
];
|
];
|
||||||
@ -197,23 +211,23 @@ private static function deriveCapabilityGroup(
|
|||||||
*/
|
*/
|
||||||
public static function deriveOverallStatus(array $permissions, bool $hasStaleFreshness = false): string
|
public static function deriveOverallStatus(array $permissions, bool $hasStaleFreshness = false): string
|
||||||
{
|
{
|
||||||
$hasMissingApplication = collect($permissions)->contains(
|
$hasBlockedApplication = collect($permissions)->contains(
|
||||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
fn (array $row): bool => in_array($row['status'], ['missing', 'blocked'], true) && $row['type'] === 'application',
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($hasMissingApplication) {
|
if ($hasBlockedApplication) {
|
||||||
return VerificationReportOverall::Blocked->value;
|
return VerificationReportOverall::Blocked->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasErrors = collect($permissions)->contains(
|
$hasNeedsReview = collect($permissions)->contains(
|
||||||
fn (array $row): bool => $row['status'] === 'error',
|
fn (array $row): bool => in_array($row['status'], ['expired', 'unknown'], true),
|
||||||
);
|
);
|
||||||
|
|
||||||
$hasMissingDelegated = collect($permissions)->contains(
|
$hasMissingDelegated = collect($permissions)->contains(
|
||||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($hasErrors || $hasMissingDelegated || $hasStaleFreshness) {
|
if ($hasNeedsReview || $hasMissingDelegated || $hasStaleFreshness) {
|
||||||
return VerificationReportOverall::NeedsAttention->value;
|
return VerificationReportOverall::NeedsAttention->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,15 +258,20 @@ public static function deriveFreshness(?CarbonInterface $lastRefreshedAt, ?Carbo
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, ManagedEnvironmentPermissionRow> $permissions
|
* @param array<int, ManagedEnvironmentPermissionRow> $permissions
|
||||||
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
public static function deriveCounts(array $permissions): array
|
public static function deriveCounts(array $permissions): array
|
||||||
{
|
{
|
||||||
$counts = [
|
$counts = [
|
||||||
'missing_application' => 0,
|
'missing_application' => 0,
|
||||||
'missing_delegated' => 0,
|
'missing_delegated' => 0,
|
||||||
'present' => 0,
|
'missing' => 0,
|
||||||
'error' => 0,
|
'granted' => 0,
|
||||||
|
'blocked' => 0,
|
||||||
|
'expired' => 0,
|
||||||
|
'unknown' => 0,
|
||||||
|
'not_applicable' => 0,
|
||||||
|
'required' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $row) {
|
foreach ($permissions as $row) {
|
||||||
@ -263,20 +282,34 @@ public static function deriveCounts(array $permissions): array
|
|||||||
$counts['missing_application'] += 1;
|
$counts['missing_application'] += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$counts['missing'] += 1;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($row['status'] ?? null) === 'granted') {
|
if (($row['status'] ?? null) === 'granted') {
|
||||||
$counts['present'] += 1;
|
$counts['granted'] += 1;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(($row['status'] ?? null), ['blocked', 'expired', 'unknown', 'not_applicable'], true)) {
|
||||||
|
$counts[(string) $row['status']] += 1;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($row['status'] ?? null) === 'error') {
|
if (($row['status'] ?? null) === 'error') {
|
||||||
$counts['error'] += 1;
|
$counts['unknown'] += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$counts['required'] = $counts['missing']
|
||||||
|
+ $counts['granted']
|
||||||
|
+ $counts['blocked']
|
||||||
|
+ $counts['expired']
|
||||||
|
+ $counts['unknown'];
|
||||||
|
|
||||||
return $counts;
|
return $counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,10 +342,10 @@ public static function deriveFeatureImpacts(array $permissions): array
|
|||||||
$impacts[$feature]['required_application'] += 1;
|
$impacts[$feature]['required_application'] += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($row['status'] ?? null) === 'missing') {
|
if (in_array(($row['status'] ?? null), ['missing', 'blocked', 'expired', 'unknown'], true)) {
|
||||||
$impacts[$feature]['missing'] += 1;
|
$impacts[$feature]['missing'] += 1;
|
||||||
|
|
||||||
if (($row['type'] ?? null) === 'application') {
|
if (($row['type'] ?? null) === 'application' && in_array(($row['status'] ?? null), ['missing', 'blocked'], true)) {
|
||||||
$impacts[$feature]['blocked'] = true;
|
$impacts[$feature]['blocked'] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -403,11 +436,11 @@ public static function applyFilterState(array $permissions, array $state): array
|
|||||||
$rowStatus = $row['status'] ?? null;
|
$rowStatus = $row['status'] ?? null;
|
||||||
$rowType = $row['type'] ?? null;
|
$rowType = $row['type'] ?? null;
|
||||||
|
|
||||||
if ($status === 'missing' && ! in_array($rowStatus, ['missing', 'error'], true)) {
|
if ($status === 'missing' && ! in_array($rowStatus, ['missing', 'blocked', 'expired', 'unknown'], true)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status === 'present' && $rowStatus !== 'granted') {
|
if ($status === 'granted' && $rowStatus !== 'granted') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -438,8 +471,11 @@ public static function applyFilterState(array $permissions, array $state): array
|
|||||||
$weight = static function (array $row): int {
|
$weight = static function (array $row): int {
|
||||||
return match ($row['status'] ?? null) {
|
return match ($row['status'] ?? null) {
|
||||||
'missing' => 0,
|
'missing' => 0,
|
||||||
'error' => 1,
|
'blocked' => 1,
|
||||||
default => 2,
|
'expired' => 2,
|
||||||
|
'unknown' => 3,
|
||||||
|
'granted' => 4,
|
||||||
|
default => 5,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -465,7 +501,7 @@ public static function normalizeFilterState(array $filters): array
|
|||||||
$features = $filters['features'] ?? [];
|
$features = $filters['features'] ?? [];
|
||||||
$search = (string) ($filters['search'] ?? '');
|
$search = (string) ($filters['search'] ?? '');
|
||||||
|
|
||||||
if (! in_array($status, ['missing', 'present', 'all'], true)) {
|
if (! in_array($status, ['missing', 'granted', 'all'], true)) {
|
||||||
$status = 'missing';
|
$status = 'missing';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,7 +550,11 @@ private static function normalizePermissionRow(array $row): array
|
|||||||
|
|
||||||
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
||||||
|
|
||||||
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
|
if ($status === 'error') {
|
||||||
|
$status = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($status, ['granted', 'missing', 'blocked', 'expired', 'unknown', 'not_applicable'], true)) {
|
||||||
$status = 'missing';
|
$status = 'missing';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -552,4 +592,59 @@ private static function parseLastRefreshedAt(mixed $value): ?Carbon
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function readinessComparison(ManagedEnvironment $tenant): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$actor = auth()->user();
|
||||||
|
$actor = $actor instanceof User ? $actor : null;
|
||||||
|
|
||||||
|
$readiness = app(ProviderReadinessResolver::class)
|
||||||
|
->forEnvironment($tenant, $actor)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'permissions' => is_array($readiness['permission_rows'] ?? null) ? $readiness['permission_rows'] : [],
|
||||||
|
'last_refreshed_at' => data_get($readiness, 'freshness.last_refreshed_at'),
|
||||||
|
'freshness' => is_array($readiness['freshness'] ?? null) ? $readiness['freshness'] : [],
|
||||||
|
'counts' => is_array($readiness['counts'] ?? null) ? $readiness['counts'] : [],
|
||||||
|
'readiness' => $readiness,
|
||||||
|
];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [
|
||||||
|
'permissions' => $this->unknownRequiredPermissionRows(),
|
||||||
|
'last_refreshed_at' => null,
|
||||||
|
'freshness' => [
|
||||||
|
'last_refreshed_at' => null,
|
||||||
|
'is_stale' => true,
|
||||||
|
],
|
||||||
|
'counts' => [],
|
||||||
|
'readiness' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:null}>
|
||||||
|
*/
|
||||||
|
private function unknownRequiredPermissionRows(): array
|
||||||
|
{
|
||||||
|
return collect($this->permissionService->getRequiredPermissions())
|
||||||
|
->filter(static fn (mixed $permission): bool => is_array($permission) && filled($permission['key'] ?? null))
|
||||||
|
->map(static fn (array $permission): array => [
|
||||||
|
'key' => (string) $permission['key'],
|
||||||
|
'type' => in_array(($permission['type'] ?? null), ['application', 'delegated'], true)
|
||||||
|
? (string) $permission['type']
|
||||||
|
: 'application',
|
||||||
|
'description' => is_string($permission['description'] ?? null) ? (string) $permission['description'] : null,
|
||||||
|
'features' => is_array($permission['features'] ?? null) ? array_values($permission['features']) : [],
|
||||||
|
'status' => 'unknown',
|
||||||
|
'details' => null,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,13 +102,15 @@ public function start(
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
|
$providerConnectionId = (int) $lockedConnection->getKey();
|
||||||
$activeRuns = OperationRun::query()
|
$activeRuns = OperationRun::query()
|
||||||
->where('managed_environment_id', $tenant->getKey())
|
->where('managed_environment_id', $tenant->getKey())
|
||||||
->active()
|
->active()
|
||||||
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
|
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->get();
|
->get()
|
||||||
|
->filter(static fn (OperationRun $run): bool => (int) data_get($run->context, 'provider_connection_id') === $providerConnectionId)
|
||||||
|
->values();
|
||||||
|
|
||||||
foreach ($activeRuns as $index => $activeRun) {
|
foreach ($activeRuns as $index => $activeRun) {
|
||||||
if ($this->runs->isStaleQueuedRun($activeRun)) {
|
if ($this->runs->isStaleQueuedRun($activeRun)) {
|
||||||
|
|||||||
@ -15,6 +15,10 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
return match ($state) {
|
return match ($state) {
|
||||||
'granted' => new BadgeSpec('Granted', 'success', 'heroicon-m-check-circle'),
|
'granted' => new BadgeSpec('Granted', 'success', 'heroicon-m-check-circle'),
|
||||||
'missing' => new BadgeSpec('Missing', 'warning', 'heroicon-m-exclamation-triangle'),
|
'missing' => new BadgeSpec('Missing', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
'expired' => new BadgeSpec('Expired', 'warning', 'heroicon-m-clock'),
|
||||||
|
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||||
|
'not_applicable' => new BadgeSpec('Not applicable', 'gray', 'heroicon-m-minus-circle'),
|
||||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -39,6 +39,9 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessResolver;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessResult;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessState;
|
||||||
use App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter;
|
use App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter;
|
||||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||||
@ -57,6 +60,7 @@ public function __construct(
|
|||||||
private readonly RestoreSafetyResolver $restoreSafetyResolver,
|
private readonly RestoreSafetyResolver $restoreSafetyResolver,
|
||||||
private readonly ManagedEnvironmentRequiredPermissionsViewModelBuilder $tenantRequiredPermissionsViewModelBuilder,
|
private readonly ManagedEnvironmentRequiredPermissionsViewModelBuilder $tenantRequiredPermissionsViewModelBuilder,
|
||||||
private readonly EvidenceAnchorResolver $evidenceAnchors,
|
private readonly EvidenceAnchorResolver $evidenceAnchors,
|
||||||
|
private readonly ProviderReadinessResolver $providerReadinessResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function build(ManagedEnvironment $tenant, ?User $user = null): EnvironmentDashboardSummary
|
public function build(ManagedEnvironment $tenant, ?User $user = null): EnvironmentDashboardSummary
|
||||||
@ -1695,17 +1699,24 @@ private function providerHealthCard(
|
|||||||
?ProviderConnection $primaryProviderConnection,
|
?ProviderConnection $primaryProviderConnection,
|
||||||
array $requiredPermissions,
|
array $requiredPermissions,
|
||||||
): array {
|
): array {
|
||||||
|
$providerReadiness = $primaryProviderConnection instanceof ProviderConnection
|
||||||
|
? $this->providerReadinessResolver->forConnection($primaryProviderConnection, $user)
|
||||||
|
: null;
|
||||||
$overview = is_array($requiredPermissions['overview'] ?? null) ? $requiredPermissions['overview'] : [];
|
$overview = is_array($requiredPermissions['overview'] ?? null) ? $requiredPermissions['overview'] : [];
|
||||||
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
$counts = $providerReadiness instanceof ProviderReadinessResult
|
||||||
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
|
? $providerReadiness->counts
|
||||||
|
: (is_array($overview['counts'] ?? null) ? $overview['counts'] : []);
|
||||||
|
$freshness = $providerReadiness instanceof ProviderReadinessResult
|
||||||
|
? $providerReadiness->freshness
|
||||||
|
: (is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'key' => 'provider_health',
|
'key' => 'provider_health',
|
||||||
'title' => $this->overviewText('readiness_provider_health_title'),
|
'title' => $this->overviewText('readiness_provider_health_title'),
|
||||||
'headline' => $this->providerHealthHeadline($primaryProviderConnection),
|
'headline' => $this->providerHealthHeadline($primaryProviderConnection),
|
||||||
'status' => $this->providerHealthStatus($primaryProviderConnection),
|
'status' => $this->providerHealthStatus($primaryProviderConnection, $providerReadiness),
|
||||||
'tone' => $this->providerHealthTone($primaryProviderConnection),
|
'tone' => $this->providerHealthTone($primaryProviderConnection, $providerReadiness),
|
||||||
'body' => $this->providerHealthDescription($primaryProviderConnection, $counts, $freshness),
|
'body' => $this->providerHealthDescription($primaryProviderConnection, $providerReadiness, $counts, $freshness),
|
||||||
'meta' => $this->cardMeta(
|
'meta' => $this->cardMeta(
|
||||||
$this->metaItem(
|
$this->metaItem(
|
||||||
$this->overviewText('readiness_provider_health_permissions_label'),
|
$this->overviewText('readiness_provider_health_permissions_label'),
|
||||||
@ -2506,47 +2517,55 @@ private function providerHealthHeadline(?ProviderConnection $connection): ?strin
|
|||||||
return $this->providerChipLabel($connection);
|
return $this->providerChipLabel($connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function providerHealthStatus(?ProviderConnection $connection): string
|
private function providerHealthStatus(?ProviderConnection $connection, ?ProviderReadinessResult $providerReadiness): string
|
||||||
{
|
{
|
||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
return $this->overviewText('readiness_provider_health_empty_status');
|
return $this->overviewText('readiness_provider_health_empty_status');
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->label;
|
return $providerReadiness?->state->label() ?? ProviderReadinessState::Unknown->label();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function providerHealthTone(?ProviderConnection $connection): string
|
private function providerHealthTone(?ProviderConnection $connection, ?ProviderReadinessResult $providerReadiness): string
|
||||||
{
|
{
|
||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
return 'gray';
|
return 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->color;
|
return match ($providerReadiness?->state) {
|
||||||
|
ProviderReadinessState::Ready => 'success',
|
||||||
|
ProviderReadinessState::Blocked,
|
||||||
|
ProviderReadinessState::Failed => 'danger',
|
||||||
|
ProviderReadinessState::NeedsAttention,
|
||||||
|
ProviderReadinessState::Expired,
|
||||||
|
ProviderReadinessState::Unknown => 'warning',
|
||||||
|
ProviderReadinessState::NotConfigured,
|
||||||
|
null => 'gray',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $counts
|
* @param array<string, mixed> $counts
|
||||||
* @param array<string, mixed> $freshness
|
* @param array<string, mixed> $freshness
|
||||||
*/
|
*/
|
||||||
private function providerHealthDescription(?ProviderConnection $connection, array $counts, array $freshness): string
|
private function providerHealthDescription(
|
||||||
{
|
?ProviderConnection $connection,
|
||||||
|
?ProviderReadinessResult $providerReadiness,
|
||||||
|
array $counts,
|
||||||
|
array $freshness,
|
||||||
|
): string {
|
||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
return $this->overviewText('readiness_provider_health_empty_description');
|
return $this->overviewText('readiness_provider_health_empty_description');
|
||||||
}
|
}
|
||||||
|
|
||||||
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
return match ($providerReadiness?->state) {
|
||||||
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
ProviderReadinessState::Ready => $this->overviewText('provider_permissions_complete_description'),
|
||||||
|
ProviderReadinessState::NeedsAttention => $this->providerPermissionsDescription($counts, $freshness),
|
||||||
if ($missingApplication > 0 || $missingDelegated > 0 || ($freshness['is_stale'] ?? false) === true) {
|
ProviderReadinessState::Blocked => $this->overviewText('readiness_provider_health_blocked_description'),
|
||||||
return $this->providerPermissionsDescription($counts, $freshness);
|
ProviderReadinessState::Expired => $this->providerPermissionsDescription($counts, array_replace($freshness, ['is_stale' => true])),
|
||||||
}
|
ProviderReadinessState::Failed => $this->overviewText('readiness_provider_health_error_description'),
|
||||||
|
ProviderReadinessState::Unknown => $this->overviewText('readiness_provider_health_unknown_description'),
|
||||||
return match ($this->providerHealthState($connection)) {
|
ProviderReadinessState::NotConfigured => $this->overviewText('readiness_provider_health_empty_description'),
|
||||||
'healthy' => $this->overviewText('provider_permissions_complete_description'),
|
|
||||||
'degraded' => $this->overviewText('readiness_provider_health_degraded_description'),
|
|
||||||
'blocked' => $this->overviewText('readiness_provider_health_blocked_description'),
|
|
||||||
'error' => $this->overviewText('readiness_provider_health_error_description'),
|
|
||||||
'pending' => $this->overviewText('readiness_provider_health_pending_description'),
|
|
||||||
default => $this->overviewText('readiness_provider_health_unknown_description'),
|
default => $this->overviewText('readiness_provider_health_unknown_description'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -2731,21 +2750,6 @@ private function riskExceptionDescription(array $exceptionStats): string
|
|||||||
return $this->overviewText('risk_exceptions_calm_description');
|
return $this->overviewText('risk_exceptions_calm_description');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function providerHealthState(?ProviderConnection $connection): string
|
|
||||||
{
|
|
||||||
if (! $connection instanceof ProviderConnection) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = $connection->verification_status;
|
|
||||||
|
|
||||||
if ($status instanceof \BackedEnum) {
|
|
||||||
return (string) $status->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim((string) ($status ?? 'unknown')) ?: 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function relativeTime(?\DateTimeInterface $timestamp): ?string
|
private function relativeTime(?\DateTimeInterface $timestamp): ?string
|
||||||
{
|
{
|
||||||
return $timestamp?->diffForHumans();
|
return $timestamp?->diffForHumans();
|
||||||
|
|||||||
@ -286,13 +286,43 @@ private function evaluatePermissionRequirements(
|
|||||||
$allRows,
|
$allRows,
|
||||||
static fn (array $row): bool => ($row['status'] ?? null) === 'missing',
|
static fn (array $row): bool => ($row['status'] ?? null) === 'missing',
|
||||||
));
|
));
|
||||||
|
$blockedRows = array_values(array_filter(
|
||||||
|
$allRows,
|
||||||
|
static fn (array $row): bool => ($row['status'] ?? null) === 'blocked',
|
||||||
|
));
|
||||||
|
$expiredRows = array_values(array_filter(
|
||||||
|
$allRows,
|
||||||
|
static fn (array $row): bool => ($row['status'] ?? null) === 'expired',
|
||||||
|
));
|
||||||
|
$unknownRows = array_values(array_filter(
|
||||||
|
$allRows,
|
||||||
|
static fn (array $row): bool => ($row['status'] ?? null) === 'unknown',
|
||||||
|
));
|
||||||
$errorRows = array_values(array_filter(
|
$errorRows = array_values(array_filter(
|
||||||
$allRows,
|
$allRows,
|
||||||
static fn (array $row): bool => ($row['status'] ?? null) === 'error',
|
static fn (array $row): bool => ($row['status'] ?? null) === 'error',
|
||||||
));
|
));
|
||||||
$missingRequirementKeys = $this->missingRequirementKeys($requirementRows);
|
$missingRequirementKeys = $this->missingRequirementKeys($requirementRows);
|
||||||
|
|
||||||
if ($errorRows !== []) {
|
if ($blockedRows !== []) {
|
||||||
|
return new ProviderCapabilityResult(
|
||||||
|
...$base,
|
||||||
|
status: ProviderCapabilityStatus::Blocked,
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
||||||
|
missingRequirementKeys: $missingRequirementKeys,
|
||||||
|
primaryMessage: "{$definition->label} capability is blocked by provider permission evidence.",
|
||||||
|
providerHint: 'Open Required Permissions to resolve the provider permission blocker.',
|
||||||
|
evidenceCounts: [
|
||||||
|
'requirements' => count($allRows),
|
||||||
|
'missing' => count($missingRows),
|
||||||
|
'errors' => count($errorRows) + count($blockedRows),
|
||||||
|
],
|
||||||
|
nextStepLabel: 'Open Required Permissions',
|
||||||
|
nextStepUrl: RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errorRows !== [] || $expiredRows !== [] || $unknownRows !== []) {
|
||||||
return new ProviderCapabilityResult(
|
return new ProviderCapabilityResult(
|
||||||
...$base,
|
...$base,
|
||||||
status: ProviderCapabilityStatus::Unknown,
|
status: ProviderCapabilityStatus::Unknown,
|
||||||
@ -303,7 +333,7 @@ private function evaluatePermissionRequirements(
|
|||||||
evidenceCounts: [
|
evidenceCounts: [
|
||||||
'requirements' => count($allRows),
|
'requirements' => count($allRows),
|
||||||
'missing' => count($missingRows),
|
'missing' => count($missingRows),
|
||||||
'errors' => count($errorRows),
|
'errors' => count($errorRows) + count($expiredRows) + count($unknownRows),
|
||||||
],
|
],
|
||||||
nextStepLabel: 'Open Required Permissions',
|
nextStepLabel: 'Open Required Permissions',
|
||||||
nextStepUrl: RequiredPermissionsLinks::requiredPermissions($tenant),
|
nextStepUrl: RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||||
@ -380,7 +410,7 @@ private function missingRequirementKeys(array $requirementRows): array
|
|||||||
|
|
||||||
foreach ($requirementRows as $requirementKey => $rows) {
|
foreach ($requirementRows as $requirementKey => $rows) {
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
if (in_array(($row['status'] ?? null), ['missing', 'error'], true)) {
|
if (in_array(($row['status'] ?? null), ['missing', 'blocked', 'expired', 'unknown', 'error'], true)) {
|
||||||
$missingKeys[] = $requirementKey;
|
$missingKeys[] = $requirementKey;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Providers\Readiness;
|
||||||
|
|
||||||
|
enum ProviderPermissionReadinessState: string
|
||||||
|
{
|
||||||
|
case Granted = 'granted';
|
||||||
|
case Missing = 'missing';
|
||||||
|
case Blocked = 'blocked';
|
||||||
|
case Expired = 'expired';
|
||||||
|
case Unknown = 'unknown';
|
||||||
|
case NotApplicable = 'not_applicable';
|
||||||
|
}
|
||||||
@ -0,0 +1,718 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Providers\Readiness;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Intune\ManagedEnvironmentPermissionService;
|
||||||
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
final class ProviderReadinessResolver
|
||||||
|
{
|
||||||
|
private const int FRESHNESS_DAYS = 30;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ManagedEnvironmentPermissionService $permissionService,
|
||||||
|
private readonly ProviderConnectionResolver $connectionResolver,
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function forConnection(ProviderConnection $connection, ?User $actor = null): ProviderReadinessResult
|
||||||
|
{
|
||||||
|
$environment = $connection->tenant instanceof ManagedEnvironment
|
||||||
|
? $connection->tenant
|
||||||
|
: ManagedEnvironment::query()->whereKey((int) $connection->managed_environment_id)->first();
|
||||||
|
|
||||||
|
if (! $environment instanceof ManagedEnvironment) {
|
||||||
|
return $this->emptyResult(
|
||||||
|
provider: $this->provider($connection),
|
||||||
|
workspaceId: is_numeric($connection->workspace_id) ? (int) $connection->workspace_id : null,
|
||||||
|
environmentId: is_numeric($connection->managed_environment_id) ? (int) $connection->managed_environment_id : null,
|
||||||
|
connectionId: (int) $connection->getKey(),
|
||||||
|
state: ProviderReadinessState::Unknown,
|
||||||
|
actor: $actor,
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
message: 'Provider readiness could not be resolved because the environment scope is unavailable.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolve(
|
||||||
|
environment: $environment,
|
||||||
|
connection: $connection,
|
||||||
|
actor: $actor,
|
||||||
|
provider: $this->provider($connection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forEnvironment(
|
||||||
|
ManagedEnvironment $environment,
|
||||||
|
?User $actor = null,
|
||||||
|
string $provider = 'microsoft',
|
||||||
|
): ProviderReadinessResult {
|
||||||
|
$resolution = $this->connectionResolver->resolveDefault($environment, $provider);
|
||||||
|
|
||||||
|
return $this->resolve(
|
||||||
|
environment: $environment,
|
||||||
|
connection: $resolution->connection,
|
||||||
|
actor: $actor,
|
||||||
|
provider: $provider,
|
||||||
|
connectionResolutionReasonCode: $resolution->effectiveReasonCode(),
|
||||||
|
connectionResolutionMessage: $resolution->message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forWorkspace(Workspace $workspace, ?User $actor = null, string $provider = 'microsoft'): ProviderReadinessResult
|
||||||
|
{
|
||||||
|
$children = ManagedEnvironment::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->filter(fn (ManagedEnvironment $environment): bool => ! $actor instanceof User || $this->capabilityResolver->isMember($actor, $environment))
|
||||||
|
->map(fn (ManagedEnvironment $environment): ProviderReadinessResult => $this->forEnvironment($environment, $actor, $provider))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->aggregate(
|
||||||
|
provider: $provider,
|
||||||
|
workspaceId: (int) $workspace->getKey(),
|
||||||
|
environmentId: null,
|
||||||
|
connectionId: null,
|
||||||
|
children: $children,
|
||||||
|
actor: $actor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ProviderReadinessResult> $children
|
||||||
|
*/
|
||||||
|
private function aggregate(
|
||||||
|
string $provider,
|
||||||
|
?int $workspaceId,
|
||||||
|
?int $environmentId,
|
||||||
|
?int $connectionId,
|
||||||
|
array $children,
|
||||||
|
?User $actor,
|
||||||
|
): ProviderReadinessResult {
|
||||||
|
if ($children === []) {
|
||||||
|
return $this->emptyResult(
|
||||||
|
provider: $provider,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
environmentId: $environmentId,
|
||||||
|
connectionId: $connectionId,
|
||||||
|
state: ProviderReadinessState::NotConfigured,
|
||||||
|
actor: $actor,
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderConnectionMissing,
|
||||||
|
message: 'No provider readiness children are configured.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $this->initialCounts();
|
||||||
|
$permissionRows = [];
|
||||||
|
$lastRefreshedAt = null;
|
||||||
|
|
||||||
|
foreach ($children as $child) {
|
||||||
|
foreach ($child->counts as $key => $value) {
|
||||||
|
if (! array_key_exists($key, $counts)) {
|
||||||
|
$counts[$key] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts[$key] += (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionRows = array_merge($permissionRows, $child->permissionRows);
|
||||||
|
|
||||||
|
$candidate = $this->parseTime($child->freshness['last_refreshed_at'] ?? null);
|
||||||
|
if ($candidate instanceof Carbon && (! $lastRefreshedAt instanceof Carbon || $candidate->gt($lastRefreshedAt))) {
|
||||||
|
$lastRefreshedAt = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $this->aggregateState($children);
|
||||||
|
|
||||||
|
return new ProviderReadinessResult(
|
||||||
|
provider: $provider,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
managedEnvironmentId: $environmentId,
|
||||||
|
providerConnectionId: $connectionId,
|
||||||
|
state: $state,
|
||||||
|
counts: $this->finalizeCounts($counts),
|
||||||
|
permissionRows: $permissionRows,
|
||||||
|
freshness: $this->freshness($lastRefreshedAt),
|
||||||
|
canManageProvider: false,
|
||||||
|
canViewTechnicalDetail: false,
|
||||||
|
recommendedAction: $this->recommendedAction($state, false),
|
||||||
|
childResults: array_map(
|
||||||
|
static fn (ProviderReadinessResult $child): array => $child->toArray(),
|
||||||
|
$children,
|
||||||
|
),
|
||||||
|
technical: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolve(
|
||||||
|
ManagedEnvironment $environment,
|
||||||
|
?ProviderConnection $connection,
|
||||||
|
?User $actor,
|
||||||
|
string $provider,
|
||||||
|
?string $connectionResolutionReasonCode = null,
|
||||||
|
?string $connectionResolutionMessage = null,
|
||||||
|
): ProviderReadinessResult {
|
||||||
|
$canManageProvider = $this->canManageProvider($actor, $environment);
|
||||||
|
$canViewTechnicalDetail = $this->canViewTechnicalDetail($actor, $environment);
|
||||||
|
$stored = $this->storedPermissions($environment);
|
||||||
|
$lastRefreshedAt = $this->latestStoredCheck($stored);
|
||||||
|
$healthCheckedAt = $this->parseTime($connection?->last_health_check_at);
|
||||||
|
$verificationState = $this->verificationState($connection);
|
||||||
|
$verificationNeverRun = $connection instanceof ProviderConnection && $healthCheckedAt === null;
|
||||||
|
$verificationStale = $healthCheckedAt instanceof Carbon && $healthCheckedAt->lt(now()->subDays(self::FRESHNESS_DAYS));
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->permissionService->getRequiredPermissions() as $permission) {
|
||||||
|
$key = (string) ($permission['key'] ?? '');
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = $this->permissionRow(
|
||||||
|
permission: $permission,
|
||||||
|
stored: $stored[$key] ?? null,
|
||||||
|
connection: $connection,
|
||||||
|
verificationState: $verificationState,
|
||||||
|
verificationNeverRun: $verificationNeverRun,
|
||||||
|
verificationStale: $verificationStale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $this->countsForRows($rows);
|
||||||
|
$state = $this->stateFor(
|
||||||
|
connection: $connection,
|
||||||
|
rows: $rows,
|
||||||
|
verificationState: $verificationState,
|
||||||
|
verificationNeverRun: $verificationNeverRun,
|
||||||
|
verificationStale: $verificationStale,
|
||||||
|
connectionResolutionReasonCode: $connectionResolutionReasonCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new ProviderReadinessResult(
|
||||||
|
provider: $provider,
|
||||||
|
workspaceId: is_numeric($environment->workspace_id) ? (int) $environment->workspace_id : null,
|
||||||
|
managedEnvironmentId: (int) $environment->getKey(),
|
||||||
|
providerConnectionId: $connection instanceof ProviderConnection ? (int) $connection->getKey() : null,
|
||||||
|
state: $state,
|
||||||
|
counts: $counts,
|
||||||
|
permissionRows: $rows,
|
||||||
|
freshness: $this->freshness($lastRefreshedAt, $healthCheckedAt),
|
||||||
|
canManageProvider: $canManageProvider,
|
||||||
|
canViewTechnicalDetail: $canViewTechnicalDetail,
|
||||||
|
recommendedAction: $this->recommendedAction($state, $canManageProvider),
|
||||||
|
technical: [
|
||||||
|
'connection_resolution_reason_code' => $connectionResolutionReasonCode,
|
||||||
|
'connection_resolution_message' => $connectionResolutionMessage,
|
||||||
|
'verification_state' => $verificationState,
|
||||||
|
'verification_never_run' => $verificationNeverRun,
|
||||||
|
'verification_stale' => $verificationStale,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $permission
|
||||||
|
* @param array{status:string,details:array<string,mixed>|null,last_checked_at:?Carbon}|null $stored
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function permissionRow(
|
||||||
|
array $permission,
|
||||||
|
?array $stored,
|
||||||
|
?ProviderConnection $connection,
|
||||||
|
string $verificationState,
|
||||||
|
bool $verificationNeverRun,
|
||||||
|
bool $verificationStale,
|
||||||
|
): array {
|
||||||
|
$storedStatus = is_string($stored['status'] ?? null) ? (string) $stored['status'] : null;
|
||||||
|
$details = is_array($stored['details'] ?? null) ? $stored['details'] : null;
|
||||||
|
$lastCheckedAt = $this->parseTime($stored['last_checked_at'] ?? null);
|
||||||
|
$storedStale = ! $lastCheckedAt instanceof Carbon || $lastCheckedAt->lt(now()->subDays(self::FRESHNESS_DAYS));
|
||||||
|
$scopeMatches = $connection instanceof ProviderConnection
|
||||||
|
&& $this->storedEvidenceMatchesConnection($details, $connection);
|
||||||
|
|
||||||
|
[$state, $reasonCode] = $this->permissionState(
|
||||||
|
storedStatus: $storedStatus,
|
||||||
|
connection: $connection,
|
||||||
|
verificationState: $verificationState,
|
||||||
|
verificationNeverRun: $verificationNeverRun,
|
||||||
|
verificationStale: $verificationStale,
|
||||||
|
storedStale: $storedStale,
|
||||||
|
scopeMatches: $scopeMatches,
|
||||||
|
details: $details,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => (string) ($permission['key'] ?? ''),
|
||||||
|
'type' => in_array(($permission['type'] ?? null), ['application', 'delegated'], true)
|
||||||
|
? (string) $permission['type']
|
||||||
|
: 'application',
|
||||||
|
'description' => is_string($permission['description'] ?? null) ? (string) $permission['description'] : null,
|
||||||
|
'features' => is_array($permission['features'] ?? null) ? array_values($permission['features']) : [],
|
||||||
|
'status' => $state->value,
|
||||||
|
'details' => $details,
|
||||||
|
'last_checked_at' => $lastCheckedAt?->toIso8601String(),
|
||||||
|
'provider_connection_id' => $connection instanceof ProviderConnection ? (int) $connection->getKey() : null,
|
||||||
|
'matched_grant_id' => $scopeMatches ? (int) ($details['provider_connection_id'] ?? 0) : null,
|
||||||
|
'is_effective' => $state === ProviderPermissionReadinessState::Granted,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $details
|
||||||
|
* @return array{0: ProviderPermissionReadinessState, 1: string}
|
||||||
|
*/
|
||||||
|
private function permissionState(
|
||||||
|
?string $storedStatus,
|
||||||
|
?ProviderConnection $connection,
|
||||||
|
string $verificationState,
|
||||||
|
bool $verificationNeverRun,
|
||||||
|
bool $verificationStale,
|
||||||
|
bool $storedStale,
|
||||||
|
bool $scopeMatches,
|
||||||
|
?array $details,
|
||||||
|
): array {
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
return [ProviderPermissionReadinessState::Unknown, ProviderReasonCodes::ProviderConnectionMissing];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storedStatus === 'granted' && ! $scopeMatches) {
|
||||||
|
return [ProviderPermissionReadinessState::Unknown, 'provider_permission_evidence_scope_mismatch'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationNeverRun) {
|
||||||
|
return [ProviderPermissionReadinessState::Unknown, 'provider_verification_not_run'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationStale || $storedStale) {
|
||||||
|
return [ProviderPermissionReadinessState::Expired, 'provider_permission_evidence_expired'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storedStatus === null) {
|
||||||
|
return $verificationState === ProviderVerificationStatus::Healthy->value
|
||||||
|
? [ProviderPermissionReadinessState::Missing, 'provider_permission_missing']
|
||||||
|
: [ProviderPermissionReadinessState::Unknown, 'provider_permission_evidence_unavailable'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storedStatus === 'granted') {
|
||||||
|
return [ProviderPermissionReadinessState::Granted, 'ok'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storedStatus === 'missing') {
|
||||||
|
return [ProviderPermissionReadinessState::Missing, 'provider_permission_missing'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storedStatus === 'error') {
|
||||||
|
$reasonCode = is_string($details['reason_code'] ?? null) ? (string) $details['reason_code'] : 'provider_permission_refresh_failed';
|
||||||
|
|
||||||
|
return in_array($reasonCode, ['authentication_failed', 'permission_denied'], true)
|
||||||
|
? [ProviderPermissionReadinessState::Blocked, $reasonCode]
|
||||||
|
: [ProviderPermissionReadinessState::Unknown, $reasonCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [ProviderPermissionReadinessState::Unknown, 'provider_permission_state_unknown'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stateFor(
|
||||||
|
?ProviderConnection $connection,
|
||||||
|
array $rows,
|
||||||
|
string $verificationState,
|
||||||
|
bool $verificationNeverRun,
|
||||||
|
bool $verificationStale,
|
||||||
|
?string $connectionResolutionReasonCode,
|
||||||
|
): ProviderReadinessState {
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
return ProviderReadinessState::NotConfigured;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) $connection->is_enabled) {
|
||||||
|
return ProviderReadinessState::NotConfigured;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($connectionResolutionReasonCode !== null && $connectionResolutionReasonCode !== 'unknown_error') {
|
||||||
|
return $this->connectionResolutionState($connectionResolutionReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$consentStatus = $this->consentState($connection);
|
||||||
|
if ($consentStatus !== ProviderConsentStatus::Granted->value) {
|
||||||
|
return ProviderReadinessState::Blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationState === ProviderVerificationStatus::Error->value || $verificationState === ProviderVerificationStatus::Degraded->value) {
|
||||||
|
return ProviderReadinessState::Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationState === ProviderVerificationStatus::Blocked->value) {
|
||||||
|
return ProviderReadinessState::Blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationNeverRun || $verificationState === ProviderVerificationStatus::Pending->value || $verificationState === ProviderVerificationStatus::Unknown->value) {
|
||||||
|
return ProviderReadinessState::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationStale || $this->containsState($rows, ProviderPermissionReadinessState::Expired)) {
|
||||||
|
return ProviderReadinessState::Expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->containsState($rows, ProviderPermissionReadinessState::Blocked)) {
|
||||||
|
return ProviderReadinessState::Blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->containsState($rows, ProviderPermissionReadinessState::Missing)) {
|
||||||
|
return ProviderReadinessState::NeedsAttention;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->containsState($rows, ProviderPermissionReadinessState::Unknown)) {
|
||||||
|
return ProviderReadinessState::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderReadinessState::Ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connectionResolutionState(string $reasonCode): ProviderReadinessState
|
||||||
|
{
|
||||||
|
return match ($reasonCode) {
|
||||||
|
ProviderReasonCodes::ProviderConnectionMissing => ProviderReadinessState::NotConfigured,
|
||||||
|
ProviderReasonCodes::ProviderConsentMissing,
|
||||||
|
ProviderReasonCodes::ProviderConsentFailed,
|
||||||
|
ProviderReasonCodes::ProviderConsentRevoked,
|
||||||
|
ProviderReasonCodes::TenantTargetMismatch,
|
||||||
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||||
|
ProviderReasonCodes::ProviderBindingUnsupported => ProviderReadinessState::Blocked,
|
||||||
|
default => ProviderReadinessState::Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function containsState(array $rows, ProviderPermissionReadinessState $state): bool
|
||||||
|
{
|
||||||
|
return collect($rows)->contains(
|
||||||
|
static fn (array $row): bool => ($row['status'] ?? null) === $state->value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ProviderReadinessResult> $children
|
||||||
|
*/
|
||||||
|
private function aggregateState(array $children): ProviderReadinessState
|
||||||
|
{
|
||||||
|
$states = array_map(static fn (ProviderReadinessResult $result): ProviderReadinessState => $result->state, $children);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
ProviderReadinessState::NotConfigured,
|
||||||
|
ProviderReadinessState::Failed,
|
||||||
|
ProviderReadinessState::Blocked,
|
||||||
|
ProviderReadinessState::Expired,
|
||||||
|
ProviderReadinessState::NeedsAttention,
|
||||||
|
ProviderReadinessState::Unknown,
|
||||||
|
] as $state) {
|
||||||
|
if (in_array($state, $states, true)) {
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderReadinessState::Ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{status:string,details:array<string,mixed>|null,last_checked_at:?Carbon}> $stored
|
||||||
|
*/
|
||||||
|
private function latestStoredCheck(array $stored): ?Carbon
|
||||||
|
{
|
||||||
|
$latest = null;
|
||||||
|
|
||||||
|
foreach ($stored as $row) {
|
||||||
|
$candidate = $this->parseTime($row['last_checked_at'] ?? null);
|
||||||
|
|
||||||
|
if ($candidate instanceof Carbon && (! $latest instanceof Carbon || $candidate->gt($latest))) {
|
||||||
|
$latest = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{status:string,details:array<string,mixed>|null,last_checked_at:?Carbon}>
|
||||||
|
*/
|
||||||
|
private function storedPermissions(ManagedEnvironment $environment): array
|
||||||
|
{
|
||||||
|
$query = ManagedEnvironmentPermission::query()
|
||||||
|
->where('managed_environment_id', (int) $environment->getKey());
|
||||||
|
|
||||||
|
if (is_numeric($environment->workspace_id)) {
|
||||||
|
$query->where('workspace_id', (int) $environment->workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->get()
|
||||||
|
->keyBy('permission_key')
|
||||||
|
->map(static fn (ManagedEnvironmentPermission $permission): array => [
|
||||||
|
'status' => (string) $permission->status,
|
||||||
|
'details' => is_array($permission->details) ? $permission->details : null,
|
||||||
|
'last_checked_at' => $permission->last_checked_at,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $details
|
||||||
|
*/
|
||||||
|
private function storedEvidenceMatchesConnection(?array $details, ProviderConnection $connection): bool
|
||||||
|
{
|
||||||
|
if (! is_array($details)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerConnectionId = $details['provider_connection_id'] ?? null;
|
||||||
|
if (! is_numeric($providerConnectionId) || (int) $providerConnectionId !== (int) $connection->getKey()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = $details['workspace_id'] ?? null;
|
||||||
|
if (! is_numeric($workspaceId) || ! is_numeric($connection->workspace_id) || (int) $workspaceId !== (int) $connection->workspace_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$environmentId = $details['managed_environment_id'] ?? null;
|
||||||
|
if (! is_numeric($environmentId) || ! is_numeric($connection->managed_environment_id) || (int) $environmentId !== (int) $connection->managed_environment_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = $details['provider'] ?? null;
|
||||||
|
|
||||||
|
return is_string($provider) && trim($provider) !== '' && trim($provider) === $this->provider($connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function countsForRows(array $rows): array
|
||||||
|
{
|
||||||
|
$counts = $this->initialCounts();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$status = (string) ($row['status'] ?? ProviderPermissionReadinessState::Unknown->value);
|
||||||
|
if (! array_key_exists($status, $counts)) {
|
||||||
|
$counts[$status] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts[$status] += 1;
|
||||||
|
|
||||||
|
if ($status === ProviderPermissionReadinessState::Missing->value) {
|
||||||
|
if (($row['type'] ?? null) === 'delegated') {
|
||||||
|
$counts['missing_delegated'] += 1;
|
||||||
|
} else {
|
||||||
|
$counts['missing_application'] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->finalizeCounts($counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function initialCounts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'required' => 0,
|
||||||
|
'granted' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'missing_application' => 0,
|
||||||
|
'missing_delegated' => 0,
|
||||||
|
'blocked' => 0,
|
||||||
|
'expired' => 0,
|
||||||
|
'unknown' => 0,
|
||||||
|
'not_applicable' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $counts
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function finalizeCounts(array $counts): array
|
||||||
|
{
|
||||||
|
$counts['required'] = (int) ($counts['granted'] ?? 0)
|
||||||
|
+ (int) ($counts['missing'] ?? 0)
|
||||||
|
+ (int) ($counts['blocked'] ?? 0)
|
||||||
|
+ (int) ($counts['expired'] ?? 0)
|
||||||
|
+ (int) ($counts['unknown'] ?? 0);
|
||||||
|
$counts['error'] = (int) ($counts['blocked'] ?? 0) + (int) ($counts['unknown'] ?? 0);
|
||||||
|
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function freshness(?Carbon $lastRefreshedAt, ?Carbon $healthCheckedAt = null): array
|
||||||
|
{
|
||||||
|
$permissionEvidenceStale = ! $lastRefreshedAt instanceof Carbon
|
||||||
|
|| $lastRefreshedAt->lt(now()->subDays(self::FRESHNESS_DAYS));
|
||||||
|
$verificationEvidenceStale = ! $healthCheckedAt instanceof Carbon
|
||||||
|
|| $healthCheckedAt->lt(now()->subDays(self::FRESHNESS_DAYS));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'last_refreshed_at' => $lastRefreshedAt?->toIso8601String(),
|
||||||
|
'last_health_check_at' => $healthCheckedAt?->toIso8601String(),
|
||||||
|
'is_stale' => $permissionEvidenceStale || $verificationEvidenceStale,
|
||||||
|
'stale_after_days' => self::FRESHNESS_DAYS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function recommendedAction(ProviderReadinessState $state, bool $canManageProvider): array
|
||||||
|
{
|
||||||
|
[$label, $actionName] = match ($state) {
|
||||||
|
ProviderReadinessState::Ready => ['View provider connection', 'viewProviderConnection'],
|
||||||
|
ProviderReadinessState::NeedsAttention => ['Review required permissions', 'reviewRequiredPermissions'],
|
||||||
|
ProviderReadinessState::Blocked => ['Resolve provider blocker', 'manageProviderConnection'],
|
||||||
|
ProviderReadinessState::NotConfigured => ['Connect provider', 'manageProviderConnection'],
|
||||||
|
ProviderReadinessState::Expired => ['Run provider verification', 'runProviderVerification'],
|
||||||
|
ProviderReadinessState::Failed => ['Review provider error', 'manageProviderConnection'],
|
||||||
|
ProviderReadinessState::Unknown => ['Check provider status', 'runProviderVerification'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => $label,
|
||||||
|
'action_name' => $actionName,
|
||||||
|
'disabled' => ! $canManageProvider && $state !== ProviderReadinessState::Ready,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emptyResult(
|
||||||
|
string $provider,
|
||||||
|
?int $workspaceId,
|
||||||
|
?int $environmentId,
|
||||||
|
?int $connectionId,
|
||||||
|
ProviderReadinessState $state,
|
||||||
|
?User $actor,
|
||||||
|
?string $reasonCode,
|
||||||
|
?string $message,
|
||||||
|
): ProviderReadinessResult {
|
||||||
|
$counts = $this->finalizeCounts($this->initialCounts());
|
||||||
|
|
||||||
|
return new ProviderReadinessResult(
|
||||||
|
provider: $provider,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
managedEnvironmentId: $environmentId,
|
||||||
|
providerConnectionId: $connectionId,
|
||||||
|
state: $state,
|
||||||
|
counts: $counts,
|
||||||
|
permissionRows: [],
|
||||||
|
freshness: $this->freshness(null),
|
||||||
|
canManageProvider: false,
|
||||||
|
canViewTechnicalDetail: false,
|
||||||
|
recommendedAction: $this->recommendedAction($state, false),
|
||||||
|
technical: [
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canManageProvider(?User $actor, ManagedEnvironment $environment): bool
|
||||||
|
{
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->capabilityResolver->isMember($actor, $environment)
|
||||||
|
&& (
|
||||||
|
$this->capabilityResolver->can($actor, $environment, Capabilities::PROVIDER_MANAGE)
|
||||||
|
|| $this->capabilityResolver->can($actor, $environment, Capabilities::PROVIDER_RUN)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canViewTechnicalDetail(?User $actor, ManagedEnvironment $environment): bool
|
||||||
|
{
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->capabilityResolver->isMember($actor, $environment)
|
||||||
|
&& (
|
||||||
|
$this->capabilityResolver->can($actor, $environment, Capabilities::PROVIDER_MANAGE)
|
||||||
|
|| $this->capabilityResolver->can($actor, $environment, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function provider(ProviderConnection $connection): string
|
||||||
|
{
|
||||||
|
$provider = trim((string) $connection->provider);
|
||||||
|
|
||||||
|
return $provider !== '' ? $provider : 'microsoft';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verificationState(?ProviderConnection $connection): string
|
||||||
|
{
|
||||||
|
$state = $connection?->verification_status;
|
||||||
|
|
||||||
|
if ($state instanceof ProviderVerificationStatus) {
|
||||||
|
return $state->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($state) && trim($state) !== ''
|
||||||
|
? trim($state)
|
||||||
|
: ProviderVerificationStatus::Unknown->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function consentState(ProviderConnection $connection): string
|
||||||
|
{
|
||||||
|
$state = $connection->consent_status;
|
||||||
|
|
||||||
|
if ($state instanceof ProviderConsentStatus) {
|
||||||
|
return $state->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($state) && trim($state) !== ''
|
||||||
|
? trim($state)
|
||||||
|
: ProviderConsentStatus::Unknown->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseTime(mixed $value): ?Carbon
|
||||||
|
{
|
||||||
|
if ($value instanceof Carbon) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof \DateTimeInterface) {
|
||||||
|
return Carbon::instance($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Providers\Readiness;
|
||||||
|
|
||||||
|
final readonly class ProviderReadinessResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $counts
|
||||||
|
* @param array<int, array<string, mixed>> $permissionRows
|
||||||
|
* @param array<string, mixed> $freshness
|
||||||
|
* @param array<string, mixed>|null $recommendedAction
|
||||||
|
* @param array<int, array<string, mixed>> $childResults
|
||||||
|
* @param array<string, mixed> $technical
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $provider,
|
||||||
|
public ?int $workspaceId,
|
||||||
|
public ?int $managedEnvironmentId,
|
||||||
|
public ?int $providerConnectionId,
|
||||||
|
public ProviderReadinessState $state,
|
||||||
|
public array $counts,
|
||||||
|
public array $permissionRows,
|
||||||
|
public array $freshness,
|
||||||
|
public bool $canManageProvider,
|
||||||
|
public bool $canViewTechnicalDetail,
|
||||||
|
public ?array $recommendedAction = null,
|
||||||
|
public array $childResults = [],
|
||||||
|
public array $technical = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'workspace_id' => $this->workspaceId,
|
||||||
|
'managed_environment_id' => $this->managedEnvironmentId,
|
||||||
|
'provider_connection_id' => $this->providerConnectionId,
|
||||||
|
'state' => $this->state->value,
|
||||||
|
'state_label' => $this->state->label(),
|
||||||
|
'counts' => $this->counts,
|
||||||
|
'permission_rows' => $this->permissionRows,
|
||||||
|
'freshness' => $this->freshness,
|
||||||
|
'can_manage_provider' => $this->canManageProvider,
|
||||||
|
'can_view_technical_detail' => $this->canViewTechnicalDetail,
|
||||||
|
'recommended_action' => $this->recommendedAction,
|
||||||
|
'child_results' => $this->childResults,
|
||||||
|
'technical' => $this->canViewTechnicalDetail ? $this->technical : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Providers\Readiness;
|
||||||
|
|
||||||
|
enum ProviderReadinessState: string
|
||||||
|
{
|
||||||
|
case Ready = 'ready';
|
||||||
|
case NeedsAttention = 'needs_attention';
|
||||||
|
case Blocked = 'blocked';
|
||||||
|
case NotConfigured = 'not_configured';
|
||||||
|
case Expired = 'expired';
|
||||||
|
case Failed = 'failed';
|
||||||
|
case Unknown = 'unknown';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Ready => 'Ready',
|
||||||
|
self::NeedsAttention => 'Needs attention',
|
||||||
|
self::Blocked => 'Blocked',
|
||||||
|
self::NotConfigured => 'Not configured',
|
||||||
|
self::Expired => 'Verification expired',
|
||||||
|
self::Failed => 'Verification failed',
|
||||||
|
self::Unknown => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Support\Providers\Capabilities\ProviderCapabilityResult;
|
use App\Support\Providers\Capabilities\ProviderCapabilityResult;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessResolver;
|
||||||
|
|
||||||
final class ProviderConnectionSurfaceSummary
|
final class ProviderConnectionSurfaceSummary
|
||||||
{
|
{
|
||||||
@ -36,17 +37,19 @@ public static function forConnection(ProviderConnection $connection): self
|
|||||||
$verificationState = self::stateValue($connection->verification_status);
|
$verificationState = self::stateValue($connection->verification_status);
|
||||||
$providerCapabilities = self::providerCapabilitiesForConnection($connection);
|
$providerCapabilities = self::providerCapabilitiesForConnection($connection);
|
||||||
$primaryProviderCapability = self::primaryProviderCapability($providerCapabilities);
|
$primaryProviderCapability = self::primaryProviderCapability($providerCapabilities);
|
||||||
|
$readinessSummary = self::resolvedReadinessSummary($connection)
|
||||||
|
?? self::readinessSummary(
|
||||||
|
isEnabled: (bool) $connection->is_enabled,
|
||||||
|
consentState: $consentState,
|
||||||
|
verificationState: $verificationState,
|
||||||
|
);
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
provider: trim((string) $connection->provider),
|
provider: trim((string) $connection->provider),
|
||||||
targetScope: $targetScope,
|
targetScope: $targetScope,
|
||||||
consentState: $consentState,
|
consentState: $consentState,
|
||||||
verificationState: $verificationState,
|
verificationState: $verificationState,
|
||||||
readinessSummary: self::readinessSummary(
|
readinessSummary: $readinessSummary,
|
||||||
isEnabled: (bool) $connection->is_enabled,
|
|
||||||
consentState: $consentState,
|
|
||||||
verificationState: $verificationState,
|
|
||||||
),
|
|
||||||
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
|
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
isEnabled: (bool) $connection->is_enabled,
|
isEnabled: (bool) $connection->is_enabled,
|
||||||
providerCapabilities: $providerCapabilities,
|
providerCapabilities: $providerCapabilities,
|
||||||
@ -154,8 +157,8 @@ private static function readinessSummary(bool $isEnabled, string $consentState,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return match ($verificationState) {
|
return match ($verificationState) {
|
||||||
ProviderVerificationStatus::Healthy->value => 'Ready',
|
ProviderVerificationStatus::Healthy->value => 'Verification checked',
|
||||||
ProviderVerificationStatus::Degraded->value => 'Ready with warnings',
|
ProviderVerificationStatus::Degraded->value => 'Verification checked with warnings',
|
||||||
ProviderVerificationStatus::Blocked->value => 'Verification blocked',
|
ProviderVerificationStatus::Blocked->value => 'Verification blocked',
|
||||||
ProviderVerificationStatus::Error->value => 'Verification failed',
|
ProviderVerificationStatus::Error->value => 'Verification failed',
|
||||||
ProviderVerificationStatus::Pending->value => 'Verification pending',
|
ProviderVerificationStatus::Pending->value => 'Verification pending',
|
||||||
@ -163,6 +166,21 @@ private static function readinessSummary(bool $isEnabled, string $consentState,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function resolvedReadinessSummary(ProviderConnection $connection): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$actor = auth()->user();
|
||||||
|
$result = app(ProviderReadinessResolver::class)->forConnection(
|
||||||
|
$connection,
|
||||||
|
$actor instanceof \App\Models\User ? $actor : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result->state->label();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -81,7 +81,7 @@ public function forConnection(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
* counts: array<string,int>,
|
||||||
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||||
* } $permissions
|
* } $permissions
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@ -99,6 +99,9 @@ private function buildCase(
|
|||||||
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||||||
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||||||
$errorCount = (int) ($counts['error'] ?? 0);
|
$errorCount = (int) ($counts['error'] ?? 0);
|
||||||
|
$blockedCount = (int) ($counts['blocked'] ?? 0);
|
||||||
|
$expiredCount = (int) ($counts['expired'] ?? 0);
|
||||||
|
$unknownCount = (int) ($counts['unknown'] ?? 0);
|
||||||
|
|
||||||
if ($resolution->effectiveReasonCode() === ProviderReasonCodes::ProviderConnectionMissing) {
|
if ($resolution->effectiveReasonCode() === ProviderReasonCodes::ProviderConnectionMissing) {
|
||||||
return $this->case(
|
return $this->case(
|
||||||
@ -286,6 +289,61 @@ private function buildCase(
|
|||||||
$verificationNotRun = $verificationState === ProviderVerificationStatus::Unknown->value || $lastHealthCheckAt === null;
|
$verificationNotRun = $verificationState === ProviderVerificationStatus::Unknown->value || $lastHealthCheckAt === null;
|
||||||
$verificationStale = $lastHealthCheckAt instanceof Carbon && $lastHealthCheckAt->lt(now()->subDays(30));
|
$verificationStale = $lastHealthCheckAt instanceof Carbon && $lastHealthCheckAt->lt(now()->subDays(30));
|
||||||
|
|
||||||
|
if ($blockedCount > 0) {
|
||||||
|
return $this->case(
|
||||||
|
key: 'provider_readiness.verification_failed',
|
||||||
|
severity: 'danger',
|
||||||
|
status: __('localization.provider_guidance.status_blocked'),
|
||||||
|
title: __('localization.provider_guidance.verification_failed_title'),
|
||||||
|
reason: __('localization.provider_guidance.verification_failed_reason'),
|
||||||
|
impact: __('localization.provider_guidance.verification_failed_impact'),
|
||||||
|
primaryAction: $latestVerificationRun instanceof OperationRun
|
||||||
|
? $this->verificationOperationAction($latestVerificationRun, $environment)
|
||||||
|
: $this->providerConnectionReviewAction($environment, $connection, $surface),
|
||||||
|
secondaryActions: $this->secondaryActionsFor(
|
||||||
|
environment: $environment,
|
||||||
|
connection: $connection,
|
||||||
|
latestVerificationRun: $latestVerificationRun,
|
||||||
|
surface: $surface,
|
||||||
|
includeRequiredPermissions: true,
|
||||||
|
includeRunVerification: $surface === self::SURFACE_REQUIRED_PERMISSIONS,
|
||||||
|
),
|
||||||
|
technicalDetails: $this->technicalDetails(
|
||||||
|
connection: $connection,
|
||||||
|
permissions: $permissions,
|
||||||
|
latestVerificationRun: $latestVerificationRun,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expiredCount > 0 || $unknownCount > 0) {
|
||||||
|
return $this->case(
|
||||||
|
key: 'provider_readiness.verification_required',
|
||||||
|
severity: 'warning',
|
||||||
|
status: __('localization.provider_guidance.status_action_required'),
|
||||||
|
title: __('localization.provider_guidance.verification_required_title'),
|
||||||
|
reason: __('localization.provider_guidance.verification_stale_reason'),
|
||||||
|
impact: __('localization.provider_guidance.verification_required_impact'),
|
||||||
|
primaryAction: $this->verificationPrimaryAction(
|
||||||
|
environment: $environment,
|
||||||
|
connection: $connection,
|
||||||
|
surface: $surface,
|
||||||
|
),
|
||||||
|
secondaryActions: $this->secondaryActionsFor(
|
||||||
|
environment: $environment,
|
||||||
|
connection: $connection,
|
||||||
|
latestVerificationRun: $latestVerificationRun,
|
||||||
|
surface: $surface,
|
||||||
|
includeRequiredPermissions: true,
|
||||||
|
),
|
||||||
|
technicalDetails: $this->technicalDetails(
|
||||||
|
connection: $connection,
|
||||||
|
permissions: $permissions,
|
||||||
|
latestVerificationRun: $latestVerificationRun,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($verificationNotRun || $verificationStale || $stalePermissionSnapshot) {
|
if ($verificationNotRun || $verificationStale || $stalePermissionSnapshot) {
|
||||||
return $this->case(
|
return $this->case(
|
||||||
key: 'provider_readiness.verification_required',
|
key: 'provider_readiness.verification_required',
|
||||||
@ -340,7 +398,7 @@ private function buildCase(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
* counts: array<string,int>,
|
||||||
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@ -353,7 +411,10 @@ private function permissionSignals(ManagedEnvironment $environment): array
|
|||||||
'counts' => array_replace([
|
'counts' => array_replace([
|
||||||
'missing_application' => 0,
|
'missing_application' => 0,
|
||||||
'missing_delegated' => 0,
|
'missing_delegated' => 0,
|
||||||
'present' => 0,
|
'granted' => 0,
|
||||||
|
'blocked' => 0,
|
||||||
|
'expired' => 0,
|
||||||
|
'unknown' => 0,
|
||||||
'error' => 0,
|
'error' => 0,
|
||||||
], is_array($overview['counts'] ?? null) ? $overview['counts'] : []),
|
], is_array($overview['counts'] ?? null) ? $overview['counts'] : []),
|
||||||
'freshness' => array_replace([
|
'freshness' => array_replace([
|
||||||
@ -530,7 +591,7 @@ private function secondaryActionsFor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
* counts: array<string,int>,
|
||||||
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||||
* } $permissions
|
* } $permissions
|
||||||
* @return list<array{label:string,value:string}>
|
* @return list<array{label:string,value:string}>
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
namespace App\Support\Verification;
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
@ -37,7 +37,7 @@ public function visibility(ManagedEnvironment $tenant, ?array $verificationRepor
|
|||||||
* verification: array{overall:?string,status:?string,is_stale:bool,stale_reason:?string},
|
* verification: array{overall:?string,status:?string,is_stale:bool,stale_reason:?string},
|
||||||
* overview: array{
|
* overview: array{
|
||||||
* overall:string,
|
* overall:string,
|
||||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
* counts: array<string,int>,
|
||||||
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||||
* },
|
* },
|
||||||
* missing_permissions: array{
|
* missing_permissions: array{
|
||||||
@ -210,12 +210,12 @@ private function deriveVisibility(?array $verificationReport, array $requiredPer
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed>|null $verificationReport
|
* @param array<string, mixed>|null $verificationReport
|
||||||
* @param array<string, mixed> $overview
|
* @param array<string, mixed> $overview
|
||||||
* @param array{missing_application:int,missing_delegated:int,present:int,error:int} $counts
|
* @param array<string,int> $counts
|
||||||
* @param array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}> $rows
|
* @param array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}> $rows
|
||||||
*/
|
*/
|
||||||
private function fallbackMessage(?array $verificationReport, array $overview, array $counts, array $rows): ?string
|
private function fallbackMessage(?array $verificationReport, array $overview, array $counts, array $rows): ?string
|
||||||
{
|
{
|
||||||
if ($counts['error'] > 0) {
|
if (($counts['error'] + $counts['blocked'] + $counts['expired'] + $counts['unknown']) > 0) {
|
||||||
return 'Some stored permission details are incomplete. Open the full page or rerun verification for a complete diagnostic view.';
|
return 'Some stored permission details are incomplete. Open the full page or rerun verification for a complete diagnostic view.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -403,14 +403,17 @@ private function normalizeOverviewOverall(mixed $overview): string
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $counts
|
* @param array<string, mixed> $counts
|
||||||
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
* @return array<string,int>
|
||||||
*/
|
*/
|
||||||
private function normalizeCounts(array $counts): array
|
private function normalizeCounts(array $counts): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'missing_application' => $this->normalizeNonNegativeInteger($counts['missing_application'] ?? 0),
|
'missing_application' => $this->normalizeNonNegativeInteger($counts['missing_application'] ?? 0),
|
||||||
'missing_delegated' => $this->normalizeNonNegativeInteger($counts['missing_delegated'] ?? 0),
|
'missing_delegated' => $this->normalizeNonNegativeInteger($counts['missing_delegated'] ?? 0),
|
||||||
'present' => $this->normalizeNonNegativeInteger($counts['present'] ?? 0),
|
'granted' => $this->normalizeNonNegativeInteger($counts['granted'] ?? 0),
|
||||||
|
'blocked' => $this->normalizeNonNegativeInteger($counts['blocked'] ?? 0),
|
||||||
|
'expired' => $this->normalizeNonNegativeInteger($counts['expired'] ?? 0),
|
||||||
|
'unknown' => $this->normalizeNonNegativeInteger($counts['unknown'] ?? 0),
|
||||||
'error' => $this->normalizeNonNegativeInteger($counts['error'] ?? 0),
|
'error' => $this->normalizeNonNegativeInteger($counts['error'] ?? 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -429,20 +432,20 @@ private function normalizeFreshness(array $freshness): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array<string, mixed>> $permissions
|
* @param array<int, array<string, mixed>> $permissions
|
||||||
* @return array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>
|
* @return array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>
|
||||||
*/
|
*/
|
||||||
private function attentionRows(array $permissions): array
|
private function attentionRows(array $permissions): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter($permissions, function (mixed $row): bool {
|
return array_values(array_filter($permissions, function (mixed $row): bool {
|
||||||
return is_array($row) && (($row['status'] ?? null) === 'missing' || ($row['status'] ?? null) === 'error');
|
return is_array($row) && in_array(($row['status'] ?? null), ['missing', 'blocked', 'expired', 'unknown', 'error'], true);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}> $rows
|
* @param array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}> $rows
|
||||||
* @return array{
|
* @return array{
|
||||||
* application: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>,
|
* application: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
|
||||||
* delegated: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>
|
* delegated: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private function partitionRows(array $rows): array
|
private function partitionRows(array $rows): array
|
||||||
|
|||||||
@ -313,17 +313,17 @@
|
|||||||
'readiness_risk_exceptions_active_label' => 'Akzeptierte Risiken',
|
'readiness_risk_exceptions_active_label' => 'Akzeptierte Risiken',
|
||||||
'readiness_risk_exceptions_expiring_label' => 'Läuft bald ab',
|
'readiness_risk_exceptions_expiring_label' => 'Läuft bald ab',
|
||||||
'readiness_risk_exceptions_pending_label' => 'Ausstehende Freigaben',
|
'readiness_risk_exceptions_pending_label' => 'Ausstehende Freigaben',
|
||||||
'readiness_provider_health_title' => 'Provider Health',
|
'readiness_provider_health_title' => 'Provider-Bereitschaft',
|
||||||
'readiness_provider_health_empty_status' => 'Provider-Status nicht verfügbar',
|
'readiness_provider_health_empty_status' => 'Provider-Status nicht verfügbar',
|
||||||
'readiness_provider_health_empty_description' => 'Für diese Umgebung liegt aktuell kein Provider-Health-Snapshot vor.',
|
'readiness_provider_health_empty_description' => 'Für diese Umgebung liegt aktuell kein Provider-Bereitschafts-Snapshot vor.',
|
||||||
'readiness_provider_health_last_check_label' => 'Letzter Check',
|
'readiness_provider_health_last_check_label' => 'Letzter Check',
|
||||||
'readiness_provider_health_permissions_label' => 'Fehlende Berechtigungen',
|
'readiness_provider_health_permissions_label' => 'Fehlende Berechtigungen',
|
||||||
'readiness_provider_health_snapshot_label' => 'Berechtigungs-Snapshot',
|
'readiness_provider_health_snapshot_label' => 'Berechtigungs-Snapshot',
|
||||||
'readiness_provider_health_degraded_description' => 'Der letzte Provider-Health-Check hat Warnungen für diese Umgebung gemeldet.',
|
'readiness_provider_health_degraded_description' => 'Die letzte Provider-Verifikation hat Warnungen für diese Umgebung gemeldet.',
|
||||||
'readiness_provider_health_blocked_description' => 'Der Provider-Health-Check ist für diese Umgebung aktuell blockiert.',
|
'readiness_provider_health_blocked_description' => 'Die Provider-Bereitschaft ist für diese Umgebung aktuell blockiert.',
|
||||||
'readiness_provider_health_error_description' => 'Der Provider-Health-Check ist für diese Umgebung fehlgeschlagen.',
|
'readiness_provider_health_error_description' => 'Die Provider-Verifikation ist für diese Umgebung fehlgeschlagen.',
|
||||||
'readiness_provider_health_pending_description' => 'Der letzte Provider-Health-Check läuft für diese Umgebung noch.',
|
'readiness_provider_health_pending_description' => 'Die letzte Provider-Verifikation läuft für diese Umgebung noch.',
|
||||||
'readiness_provider_health_unknown_description' => 'Für diesen Tenant wurde noch kein Provider-Health-Check erfasst.',
|
'readiness_provider_health_unknown_description' => 'Für diesen Tenant wurde noch keine Provider-Verifikation erfasst.',
|
||||||
'helper_findings_requires_permissions' => 'Sie sehen den Umgebungsstatus, aber zum Öffnen von Findings sind zusätzliche Berechtigungen erforderlich.',
|
'helper_findings_requires_permissions' => 'Sie sehen den Umgebungsstatus, aber zum Öffnen von Findings sind zusätzliche Berechtigungen erforderlich.',
|
||||||
'helper_risk_exceptions_requires_permissions' => 'Sie sehen die Zusammenfassung, aber zum Öffnen von Risikoausnahmen sind zusätzliche Berechtigungen erforderlich.',
|
'helper_risk_exceptions_requires_permissions' => 'Sie sehen die Zusammenfassung, aber zum Öffnen von Risikoausnahmen sind zusätzliche Berechtigungen erforderlich.',
|
||||||
'helper_review_requires_permissions' => 'Sie sehen die Zusammenfassung, aber zum Öffnen der Review-Details sind zusätzliche Berechtigungen erforderlich.',
|
'helper_review_requires_permissions' => 'Sie sehen die Zusammenfassung, aber zum Öffnen der Review-Details sind zusätzliche Berechtigungen erforderlich.',
|
||||||
@ -417,7 +417,7 @@
|
|||||||
'detail_permission_evidence_label' => 'Gespeicherte Berechtigungsevidenz',
|
'detail_permission_evidence_label' => 'Gespeicherte Berechtigungsevidenz',
|
||||||
'detail_consent_state_label' => 'Zustimmungsstatus',
|
'detail_consent_state_label' => 'Zustimmungsstatus',
|
||||||
'detail_last_verification_operation_label' => 'Letzter Verifikationsvorgang',
|
'detail_last_verification_operation_label' => 'Letzter Verifikationsvorgang',
|
||||||
'verification_ready_detail' => 'Bereit',
|
'verification_ready_detail' => 'Letzte Prüfung bestanden',
|
||||||
'verification_degraded_detail' => 'Beeinträchtigt',
|
'verification_degraded_detail' => 'Beeinträchtigt',
|
||||||
'verification_blocked_detail' => 'Blockiert',
|
'verification_blocked_detail' => 'Blockiert',
|
||||||
'verification_failed_detail' => 'Fehlgeschlagen',
|
'verification_failed_detail' => 'Fehlgeschlagen',
|
||||||
|
|||||||
@ -313,17 +313,17 @@
|
|||||||
'readiness_risk_exceptions_active_label' => 'Accepted risks',
|
'readiness_risk_exceptions_active_label' => 'Accepted risks',
|
||||||
'readiness_risk_exceptions_expiring_label' => 'Expiring soon',
|
'readiness_risk_exceptions_expiring_label' => 'Expiring soon',
|
||||||
'readiness_risk_exceptions_pending_label' => 'Pending approval',
|
'readiness_risk_exceptions_pending_label' => 'Pending approval',
|
||||||
'readiness_provider_health_title' => 'Provider Health',
|
'readiness_provider_health_title' => 'Provider readiness',
|
||||||
'readiness_provider_health_empty_status' => 'Provider status unavailable',
|
'readiness_provider_health_empty_status' => 'Provider status unavailable',
|
||||||
'readiness_provider_health_empty_description' => 'No provider health snapshot is currently available for this environment.',
|
'readiness_provider_health_empty_description' => 'No provider readiness snapshot is currently available for this environment.',
|
||||||
'readiness_provider_health_last_check_label' => 'Last check',
|
'readiness_provider_health_last_check_label' => 'Last check',
|
||||||
'readiness_provider_health_permissions_label' => 'Missing permissions',
|
'readiness_provider_health_permissions_label' => 'Missing permissions',
|
||||||
'readiness_provider_health_snapshot_label' => 'Permissions snapshot',
|
'readiness_provider_health_snapshot_label' => 'Permissions snapshot',
|
||||||
'readiness_provider_health_degraded_description' => 'The latest provider health check reported warnings for this environment.',
|
'readiness_provider_health_degraded_description' => 'The latest provider verification reported warnings for this environment.',
|
||||||
'readiness_provider_health_blocked_description' => 'The provider health check is currently blocked for this environment.',
|
'readiness_provider_health_blocked_description' => 'Provider readiness is currently blocked for this environment.',
|
||||||
'readiness_provider_health_error_description' => 'The provider health check failed for this environment.',
|
'readiness_provider_health_error_description' => 'Provider verification failed for this environment.',
|
||||||
'readiness_provider_health_pending_description' => 'The latest provider health check is still pending for this environment.',
|
'readiness_provider_health_pending_description' => 'The latest provider verification is still pending for this environment.',
|
||||||
'readiness_provider_health_unknown_description' => 'No provider health check has been recorded yet.',
|
'readiness_provider_health_unknown_description' => 'No provider verification has been recorded yet.',
|
||||||
'helper_findings_requires_permissions' => 'You can see the environment posture, but opening findings requires additional permissions.',
|
'helper_findings_requires_permissions' => 'You can see the environment posture, but opening findings requires additional permissions.',
|
||||||
'helper_risk_exceptions_requires_permissions' => 'You can see the summary, but opening risk exceptions requires additional permissions.',
|
'helper_risk_exceptions_requires_permissions' => 'You can see the summary, but opening risk exceptions requires additional permissions.',
|
||||||
'helper_review_requires_permissions' => 'You can see the summary, but opening review detail requires additional permissions.',
|
'helper_review_requires_permissions' => 'You can see the summary, but opening review detail requires additional permissions.',
|
||||||
@ -417,7 +417,7 @@
|
|||||||
'detail_permission_evidence_label' => 'Stored permission evidence',
|
'detail_permission_evidence_label' => 'Stored permission evidence',
|
||||||
'detail_consent_state_label' => 'Consent state',
|
'detail_consent_state_label' => 'Consent state',
|
||||||
'detail_last_verification_operation_label' => 'Last verification operation',
|
'detail_last_verification_operation_label' => 'Last verification operation',
|
||||||
'verification_ready_detail' => 'Ready',
|
'verification_ready_detail' => 'Last check passed',
|
||||||
'verification_degraded_detail' => 'Degraded',
|
'verification_degraded_detail' => 'Degraded',
|
||||||
'verification_blocked_detail' => 'Blocked',
|
'verification_blocked_detail' => 'Blocked',
|
||||||
'verification_failed_detail' => 'Failed',
|
'verification_failed_detail' => 'Failed',
|
||||||
|
|||||||
@ -39,6 +39,22 @@
|
|||||||
$grantAdminConsentAction = is_array($actions['grant_admin_consent'] ?? null) ? $actions['grant_admin_consent'] : [];
|
$grantAdminConsentAction = is_array($actions['grant_admin_consent'] ?? null) ? $actions['grant_admin_consent'] : [];
|
||||||
$manageProviderConnectionAction = is_array($actions['manage_provider_connection'] ?? null) ? $actions['manage_provider_connection'] : [];
|
$manageProviderConnectionAction = is_array($actions['manage_provider_connection'] ?? null) ? $actions['manage_provider_connection'] : [];
|
||||||
$rerunVerificationAction = is_array($actions['rerun_verification'] ?? null) ? $actions['rerun_verification'] : [];
|
$rerunVerificationAction = is_array($actions['rerun_verification'] ?? null) ? $actions['rerun_verification'] : [];
|
||||||
|
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||||||
|
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||||||
|
$grantedCount = (int) ($counts['granted'] ?? 0);
|
||||||
|
$blockedCount = (int) ($counts['blocked'] ?? 0);
|
||||||
|
$expiredCount = (int) ($counts['expired'] ?? 0);
|
||||||
|
$unknownCount = (int) ($counts['unknown'] ?? 0);
|
||||||
|
$missingCount = (int) ($counts['missing'] ?? ($missingApplication + $missingDelegated));
|
||||||
|
$requiredCount = (int) ($counts['required'] ?? ($grantedCount + $missingCount + $blockedCount + $expiredCount + $unknownCount));
|
||||||
|
$canonicalCountCards = [
|
||||||
|
['label' => 'Required', 'value' => $requiredCount, 'detail' => 'Rows in scope'],
|
||||||
|
['label' => 'Granted', 'value' => $grantedCount, 'detail' => 'Effective grants'],
|
||||||
|
['label' => 'Missing', 'value' => $missingCount, 'detail' => "{$missingApplication} app / {$missingDelegated} delegated"],
|
||||||
|
['label' => 'Blocked', 'value' => $blockedCount, 'detail' => 'Consent or policy'],
|
||||||
|
['label' => 'Expired', 'value' => $expiredCount, 'detail' => 'Stale evidence'],
|
||||||
|
['label' => 'Unknown', 'value' => $unknownCount, 'detail' => 'Untrusted evidence'],
|
||||||
|
];
|
||||||
|
|
||||||
$renderActionLink = static function (array $action, string $testId, string $tone = 'primary'): string {
|
$renderActionLink = static function (array $action, string $testId, string $tone = 'primary'): string {
|
||||||
$label = is_string($action['label'] ?? null) ? trim((string) $action['label']) : '';
|
$label = is_string($action['label'] ?? null) ? trim((string) $action['label']) : '';
|
||||||
@ -100,23 +116,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid auto-rows-fr grid-cols-2 gap-2 sm:grid-cols-4">
|
<div class="grid auto-rows-fr grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6">
|
||||||
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
|
@foreach ($canonicalCountCards as $countCard)
|
||||||
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Missing (app)</div>
|
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
|
||||||
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['missing_application'] ?? 0) }}</div>
|
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">{{ $countCard['label'] }}</div>
|
||||||
</div>
|
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ $countCard['value'] }}</div>
|
||||||
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
|
<div class="text-[11px] leading-tight text-gray-500 dark:text-gray-400">{{ $countCard['detail'] }}</div>
|
||||||
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Missing (delegated)</div>
|
</div>
|
||||||
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['missing_delegated'] ?? 0) }}</div>
|
@endforeach
|
||||||
</div>
|
|
||||||
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
|
|
||||||
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Present</div>
|
|
||||||
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['present'] ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
|
|
||||||
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Errors</div>
|
|
||||||
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['error'] ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,7 +11,8 @@
|
|||||||
$lifecycle = is_string($state['lifecycle'] ?? null) ? (string) $state['lifecycle'] : null;
|
$lifecycle = is_string($state['lifecycle'] ?? null) ? (string) $state['lifecycle'] : null;
|
||||||
$isEnabled = $state['is_enabled'] ?? null;
|
$isEnabled = $state['is_enabled'] ?? null;
|
||||||
$consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
|
$consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
|
||||||
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
|
$readinessStatus = is_string($state['readiness_status'] ?? null) ? (string) $state['readiness_status'] : null;
|
||||||
|
$readinessTone = is_string($state['readiness_tone'] ?? null) ? (string) $state['readiness_tone'] : 'gray';
|
||||||
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
|
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
|
||||||
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
|
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
|
||||||
$targetScopeSummary = is_string($state['target_scope_summary'] ?? null) ? (string) $state['target_scope_summary'] : null;
|
$targetScopeSummary = is_string($state['target_scope_summary'] ?? null) ? (string) $state['target_scope_summary'] : null;
|
||||||
@ -19,7 +20,6 @@
|
|||||||
$isMissing = $connectionState === 'missing';
|
$isMissing = $connectionState === 'missing';
|
||||||
$lifecycleSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $isEnabled ?? $lifecycle);
|
$lifecycleSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $isEnabled ?? $lifecycle);
|
||||||
$consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
|
$consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
|
||||||
$verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus);
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
@ -73,10 +73,10 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Verification</dt>
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Readiness</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<x-filament::badge :color="$verificationSpec->color" :icon="$verificationSpec->icon" size="sm">
|
<x-filament::badge :color="$readinessTone" size="sm">
|
||||||
{{ $verificationSpec->label }}
|
{{ $readinessStatus ?? 'Unknown' }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,21 +26,32 @@
|
|||||||
|
|
||||||
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||||||
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||||||
$presentCount = (int) ($counts['present'] ?? 0);
|
$grantedCount = (int) ($counts['granted'] ?? 0);
|
||||||
$errorCount = (int) ($counts['error'] ?? 0);
|
$blockedCount = (int) ($counts['blocked'] ?? 0);
|
||||||
|
$expiredCount = (int) ($counts['expired'] ?? 0);
|
||||||
|
$unknownCount = (int) ($counts['unknown'] ?? 0);
|
||||||
|
|
||||||
$missingTotal = $missingApplication + $missingDelegated + $errorCount;
|
$needsReviewCount = $blockedCount + $expiredCount + $unknownCount;
|
||||||
$requiredTotal = $missingTotal + $presentCount;
|
$missingTotal = $missingApplication + $missingDelegated + $needsReviewCount;
|
||||||
|
$requiredTotal = (int) ($counts['required'] ?? ($missingTotal + $grantedCount));
|
||||||
$adminConsentUrl = $tenant ? RequiredPermissionsLinks::adminConsentUrl($tenant) : null;
|
$canonicalCountCards = [
|
||||||
$adminConsentPrimaryUrl = $tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant) : RequiredPermissionsLinks::adminConsentGuideUrl();
|
['label' => 'Required', 'value' => $requiredTotal, 'detail' => 'Rows in scope'],
|
||||||
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
|
['label' => 'Granted', 'value' => $grantedCount, 'detail' => 'Effective grants'],
|
||||||
|
['label' => 'Missing', 'value' => $missingApplication + $missingDelegated, 'detail' => "{$missingApplication} app / {$missingDelegated} delegated"],
|
||||||
|
['label' => 'Blocked', 'value' => $blockedCount, 'detail' => 'Consent or policy'],
|
||||||
|
['label' => 'Expired', 'value' => $expiredCount, 'detail' => 'Stale evidence'],
|
||||||
|
['label' => 'Unknown', 'value' => $unknownCount, 'detail' => 'Untrusted evidence'],
|
||||||
|
];
|
||||||
|
|
||||||
$reRunUrl = $this->reRunVerificationUrl();
|
$reRunUrl = $this->reRunVerificationUrl();
|
||||||
$manageProviderConnectionUrl = $this->manageProviderConnectionUrl();
|
$manageProviderConnectionUrl = $this->manageProviderConnectionUrl();
|
||||||
$guidance = $this->guidanceCase();
|
$guidance = $this->guidanceCase();
|
||||||
$guidancePrimaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
$guidancePrimaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
||||||
$canRunProviderVerification = $this->canRunProviderVerification();
|
$canRunProviderVerification = $this->canRunProviderVerification();
|
||||||
|
$canManageProviderConnection = $this->canManageProviderConnection();
|
||||||
|
$adminConsentUrl = $canManageProviderConnection && $tenant ? RequiredPermissionsLinks::adminConsentUrl($tenant) : null;
|
||||||
|
$adminConsentPrimaryUrl = $canManageProviderConnection && $tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant) : null;
|
||||||
|
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
|
||||||
$showGuidancePrimaryAction = (is_string($guidancePrimaryAction['url'] ?? null) && $guidancePrimaryAction['url'] !== '')
|
$showGuidancePrimaryAction = (is_string($guidancePrimaryAction['url'] ?? null) && $guidancePrimaryAction['url'] !== '')
|
||||||
|| ($canRunProviderVerification && ($guidancePrimaryAction['action_name'] ?? null) === 'runProviderVerification');
|
|| ($canRunProviderVerification && ($guidancePrimaryAction['action_name'] ?? null) === 'runProviderVerification');
|
||||||
$lastRefreshedAt = is_string($freshness['last_refreshed_at'] ?? null) ? (string) $freshness['last_refreshed_at'] : null;
|
$lastRefreshedAt = is_string($freshness['last_refreshed_at'] ?? null) ? (string) $freshness['last_refreshed_at'] : null;
|
||||||
@ -56,8 +67,8 @@
|
|||||||
'title' => 'Missing application permissions',
|
'title' => 'Missing application permissions',
|
||||||
'description' => "{$missingApplication} required application permission(s) are missing.",
|
'description' => "{$missingApplication} required application permission(s) are missing.",
|
||||||
'links' => array_values(array_filter([
|
'links' => array_values(array_filter([
|
||||||
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
|
$adminConsentPrimaryUrl ? ['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true] : null,
|
||||||
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : null,
|
$canManageProviderConnection && $manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : null,
|
||||||
['label' => 'Open environment dashboard', 'url' => $reRunUrl, 'external' => false],
|
['label' => 'Open environment dashboard', 'url' => $reRunUrl, 'external' => false],
|
||||||
])),
|
])),
|
||||||
];
|
];
|
||||||
@ -68,21 +79,20 @@
|
|||||||
'severity' => 'Warning',
|
'severity' => 'Warning',
|
||||||
'title' => 'Missing delegated permissions',
|
'title' => 'Missing delegated permissions',
|
||||||
'description' => "{$missingDelegated} delegated permission(s) are missing.",
|
'description' => "{$missingDelegated} delegated permission(s) are missing.",
|
||||||
'links' => [
|
'links' => array_values(array_filter([
|
||||||
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
|
$adminConsentPrimaryUrl ? ['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true] : null,
|
||||||
['label' => 'Open provider connection', 'url' => $manageProviderConnectionUrl ?? $reRunUrl, 'external' => false],
|
['label' => 'Open provider connection', 'url' => $manageProviderConnectionUrl ?? $reRunUrl, 'external' => false],
|
||||||
],
|
])),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($errorCount > 0) {
|
if ($blockedCount > 0 || $expiredCount > 0 || $unknownCount > 0) {
|
||||||
$issues[] = [
|
$issues[] = [
|
||||||
'severity' => 'Warning',
|
'severity' => $blockedCount > 0 ? 'Blocker' : 'Warning',
|
||||||
'title' => 'Verification results need review',
|
'title' => 'Permission evidence needs review',
|
||||||
'description' => "{$errorCount} permission row(s) are in an unknown/error state and require follow-up.",
|
'description' => "{$needsReviewCount} permission evidence row(s) are blocked, expired, or not tied to the current provider connection.",
|
||||||
'links' => [
|
'links' => [
|
||||||
['label' => 'Open provider connection', 'url' => $manageProviderConnectionUrl ?? $reRunUrl, 'external' => false],
|
['label' => 'Open provider connection', 'url' => $manageProviderConnectionUrl ?? $reRunUrl, 'external' => false],
|
||||||
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : ['label' => 'Admin consent guide', 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), 'external' => true],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -118,23 +128,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid w-full grid-cols-2 gap-2 sm:w-auto sm:grid-cols-4">
|
<div class="grid w-full grid-cols-2 gap-2 sm:grid-cols-3 xl:w-auto xl:grid-cols-6">
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
@foreach ($canonicalCountCards as $countCard)
|
||||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (app)</div>
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_application'] ?? 0) }}</div>
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ $countCard['label'] }}</div>
|
||||||
</div>
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ $countCard['value'] }}</div>
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
<div class="mt-0.5 text-[11px] leading-tight text-gray-500 dark:text-gray-400">{{ $countCard['detail'] }}</div>
|
||||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (delegated)</div>
|
</div>
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_delegated'] ?? 0) }}</div>
|
@endforeach
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
|
||||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Present</div>
|
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['present'] ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
|
||||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Errors</div>
|
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['error'] ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -204,6 +205,8 @@ class="font-medium underline"
|
|||||||
$capabilityCounts = is_array($capabilityGroup['evidence_counts'] ?? null) ? $capabilityGroup['evidence_counts'] : [];
|
$capabilityCounts = is_array($capabilityGroup['evidence_counts'] ?? null) ? $capabilityGroup['evidence_counts'] : [];
|
||||||
$missing = (int) ($capabilityCounts['missing'] ?? 0);
|
$missing = (int) ($capabilityCounts['missing'] ?? 0);
|
||||||
$errors = (int) ($capabilityCounts['errors'] ?? 0);
|
$errors = (int) ($capabilityCounts['errors'] ?? 0);
|
||||||
|
$expired = (int) ($capabilityCounts['expired'] ?? 0);
|
||||||
|
$unknown = (int) ($capabilityCounts['unknown'] ?? 0);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
|
||||||
@ -223,7 +226,7 @@ class="font-medium underline"
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ $missing }} missing, {{ $errors }} error(s)
|
{{ $missing }} missing, {{ $errors + $expired + $unknown }} need review
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
@ -240,14 +243,18 @@ class="font-medium underline"
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">Primary next step:</span>
|
<span class="font-medium">Primary next step:</span>
|
||||||
<a
|
@if ($adminConsentPrimaryUrl)
|
||||||
href="{{ $adminConsentPrimaryUrl }}"
|
<a
|
||||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
href="{{ $adminConsentPrimaryUrl }}"
|
||||||
target="_blank"
|
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{{ $adminConsentLabel }}
|
>
|
||||||
</a>
|
{{ $adminConsentLabel }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
Copy missing permissions and hand them to a Global Administrator / Privileged Role Administrator.
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@if ($canRunProviderVerification)
|
@if ($canRunProviderVerification)
|
||||||
<div>
|
<div>
|
||||||
@ -260,7 +267,7 @@ class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
|||||||
Run provider verification
|
Run provider verification
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@elseif ($manageProviderConnectionUrl)
|
@elseif ($canManageProviderConnection && $manageProviderConnectionUrl)
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">After granting consent:</span>
|
<span class="font-medium">After granting consent:</span>
|
||||||
<a
|
<a
|
||||||
@ -519,13 +526,13 @@ class="text-primary-600 hover:underline dark:text-primary-400"
|
|||||||
@endif
|
@endif
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
<x-filament::section heading="Passed">
|
<x-filament::section heading="Granted">
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||||
<div class="font-semibold text-gray-950 dark:text-white">
|
<div class="font-semibold text-gray-950 dark:text-white">
|
||||||
{{ $presentCount }} permission(s) currently pass.
|
{{ $grantedCount }} permission(s) currently granted.
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
{{ $requiredTotal > 0 ? "Out of {$requiredTotal} required permissions, {$presentCount} are currently granted." : 'No required permissions are configured yet.' }}
|
{{ $requiredTotal > 0 ? "Out of {$requiredTotal} required permissions, {$grantedCount} are currently granted." : 'No required permissions are configured yet.' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@ -84,7 +84,7 @@
|
|||||||
->assertSee('Open operations hub')
|
->assertSee('Open operations hub')
|
||||||
->assertSee('Current review')
|
->assertSee('Current review')
|
||||||
->assertSee('Risk exceptions')
|
->assertSee('Risk exceptions')
|
||||||
->assertSee('Provider Health')
|
->assertSee('Provider readiness')
|
||||||
->assertSee('Customer-safe output')
|
->assertSee('Customer-safe output')
|
||||||
->assertDontSee('Recent operations')
|
->assertDontSee('Recent operations')
|
||||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"]').length === 4", true)
|
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"]').length === 4", true)
|
||||||
|
|||||||
@ -94,7 +94,7 @@
|
|||||||
visit(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
|
visit(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertSee('Environment governance overview')
|
->assertSee('Environment governance overview')
|
||||||
->assertSee('Provider Health')
|
->assertSee('Provider readiness')
|
||||||
->assertSee('Provider status unavailable')
|
->assertSee('Provider status unavailable')
|
||||||
->assertDontSee(OperationRunLinks::openLabel());
|
->assertDontSee(OperationRunLinks::openLabel());
|
||||||
});
|
});
|
||||||
|
|||||||
@ -188,7 +188,7 @@ function spec192ApprovedFindingException(ManagedEnvironment $tenant, User $reque
|
|||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
->assertSee((string) $tenant->name)
|
->assertSee((string) $tenant->name)
|
||||||
->assertSee('Provider Health');
|
->assertSee('Provider readiness');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('smokes the compliant reference baseline without header regressions or javascript errors', function (): void {
|
it('smokes the compliant reference baseline without header regressions or javascript errors', function (): void {
|
||||||
|
|||||||
@ -83,10 +83,11 @@
|
|||||||
->assertNoConsoleLogs();
|
->assertNoConsoleLogs();
|
||||||
|
|
||||||
visit(ManagedEnvironmentLinks::viewUrl($tenant))
|
visit(ManagedEnvironmentLinks::viewUrl($tenant))
|
||||||
->waitForText('Provider Health')
|
->waitForText('Provider readiness')
|
||||||
->assertScript("window.location.pathname === '{$tenantViewPath}'", true)
|
->assertScript("window.location.pathname === '{$tenantViewPath}'", true)
|
||||||
->assertSee('Spec 281 Browser Environment')
|
->assertSee('Spec 281 Browser Environment')
|
||||||
->assertSee('Healthy')
|
->assertSee('Readiness')
|
||||||
|
->assertDontSee('Healthy')
|
||||||
->assertSee('Granted')
|
->assertSee('Granted')
|
||||||
->assertSee('Open Provider Connections')
|
->assertSee('Open Provider Connections')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
|
|||||||
@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
pest()->browser()->timeout(40_000);
|
||||||
|
|
||||||
|
function spec394BrowserConfigurePermissions(): void
|
||||||
|
{
|
||||||
|
config()->set('intune_permissions.permissions', [
|
||||||
|
[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'Group.Read.All',
|
||||||
|
'type' => 'delegated',
|
||||||
|
'description' => 'Read groups',
|
||||||
|
'features' => ['restore'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
config()->set('entra_permissions.permissions', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec394BrowserConnection(ManagedEnvironment $environment, array $attributes = []): ProviderConnection
|
||||||
|
{
|
||||||
|
return ProviderConnection::factory()
|
||||||
|
->platform()
|
||||||
|
->verifiedHealthy()
|
||||||
|
->create(array_replace([
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec394BrowserPermission(
|
||||||
|
ManagedEnvironment $environment,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
string $permissionKey,
|
||||||
|
string $status = 'granted',
|
||||||
|
): void {
|
||||||
|
ManagedEnvironmentPermission::query()->create([
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'permission_key' => $permissionKey,
|
||||||
|
'status' => $status,
|
||||||
|
'details' => [
|
||||||
|
'source' => 'spec394-browser-smoke',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'grant_id' => 'raw-grant-id-must-not-render',
|
||||||
|
],
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{user: User, workspace: Workspace, staleEnvironment: ManagedEnvironment, staleConnection: ProviderConnection, readyEnvironment: ManagedEnvironment, missingEnvironment: ManagedEnvironment}
|
||||||
|
*/
|
||||||
|
function spec394BrowserFixture(): array
|
||||||
|
{
|
||||||
|
spec394BrowserConfigurePermissions();
|
||||||
|
|
||||||
|
[$user, $staleEnvironment] = createUserWithTenant(
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspace = $staleEnvironment->workspace()->firstOrFail();
|
||||||
|
$staleEnvironment->forceFill(['name' => 'Spec394 Browser Stale Environment'])->save();
|
||||||
|
|
||||||
|
$readyEnvironment = ManagedEnvironment::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Spec394 Browser Ready Environment',
|
||||||
|
]);
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $readyEnvironment->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$missingEnvironment = ManagedEnvironment::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Spec394 Browser Missing Permissions Environment',
|
||||||
|
]);
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $missingEnvironment->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staleConnection = spec394BrowserConnection($staleEnvironment, [
|
||||||
|
'display_name' => 'Spec394 Browser Expired Connection',
|
||||||
|
'last_health_check_at' => now()->subDays(31),
|
||||||
|
]);
|
||||||
|
spec394BrowserPermission($staleEnvironment, $staleConnection, 'DeviceManagementApps.Read.All');
|
||||||
|
spec394BrowserPermission($staleEnvironment, $staleConnection, 'Group.Read.All');
|
||||||
|
|
||||||
|
$readyConnection = spec394BrowserConnection($readyEnvironment, [
|
||||||
|
'display_name' => 'Spec394 Browser Ready Connection',
|
||||||
|
]);
|
||||||
|
spec394BrowserPermission($readyEnvironment, $readyConnection, 'DeviceManagementApps.Read.All');
|
||||||
|
spec394BrowserPermission($readyEnvironment, $readyConnection, 'Group.Read.All');
|
||||||
|
|
||||||
|
$missingConnection = spec394BrowserConnection($missingEnvironment, [
|
||||||
|
'display_name' => 'Spec394 Browser Missing Connection',
|
||||||
|
]);
|
||||||
|
spec394BrowserPermission($missingEnvironment, $missingConnection, 'DeviceManagementApps.Read.All', 'missing');
|
||||||
|
spec394BrowserPermission($missingEnvironment, $missingConnection, 'Group.Read.All', 'missing');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user' => $user,
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'staleEnvironment' => $staleEnvironment,
|
||||||
|
'staleConnection' => $staleConnection,
|
||||||
|
'readyEnvironment' => $readyEnvironment,
|
||||||
|
'missingEnvironment' => $missingEnvironment,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec394BrowserActAs(User $user, Workspace $workspace, ManagedEnvironment $environment): void
|
||||||
|
{
|
||||||
|
test()->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||||
|
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||||
|
(string) $workspace->getKey() => (int) $environment->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('smokes stale provider freshness and ready required-permissions semantics', function (): void {
|
||||||
|
$fixture = spec394BrowserFixture();
|
||||||
|
|
||||||
|
spec394BrowserActAs($fixture['user'], $fixture['workspace'], $fixture['staleEnvironment']);
|
||||||
|
|
||||||
|
visit(ProviderConnectionResource::getUrl('view', [
|
||||||
|
'record' => $fixture['staleConnection'],
|
||||||
|
'environment_id' => (int) $fixture['staleEnvironment']->getKey(),
|
||||||
|
], panel: 'admin'))
|
||||||
|
->waitForText('Verification expired')
|
||||||
|
->assertDontSee('Healthy')
|
||||||
|
->assertDontSee('Ready -')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs();
|
||||||
|
|
||||||
|
spec394BrowserActAs($fixture['user'], $fixture['workspace'], $fixture['readyEnvironment']);
|
||||||
|
|
||||||
|
visit(RequiredPermissionsLinks::requiredPermissions($fixture['readyEnvironment']))
|
||||||
|
->waitForText('2 permission(s) currently granted.')
|
||||||
|
->assertSee('Ready')
|
||||||
|
->assertSee('2 permission(s) currently granted.')
|
||||||
|
->assertSee('Out of 2 required permissions, 2 are currently granted.')
|
||||||
|
->assertDontSee('Present 0')
|
||||||
|
->assertDontSee('raw-grant-id-must-not-render')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs();
|
||||||
|
|
||||||
|
spec394BrowserActAs($fixture['user'], $fixture['workspace'], $fixture['missingEnvironment']);
|
||||||
|
|
||||||
|
visit(RequiredPermissionsLinks::requiredPermissions($fixture['missingEnvironment'], ['status' => 'all']))
|
||||||
|
->waitForText('Missing application permissions')
|
||||||
|
->assertSee('Missing delegated permissions')
|
||||||
|
->assertSee('Required')
|
||||||
|
->assertSee('Granted')
|
||||||
|
->assertSee('Missing')
|
||||||
|
->assertSee('Blocked')
|
||||||
|
->assertSee('Expired')
|
||||||
|
->assertSee('Unknown')
|
||||||
|
->assertSee('Copy missing application permissions')
|
||||||
|
->assertSee('Copy missing delegated permissions')
|
||||||
|
->assertDontSee('Present 0')
|
||||||
|
->assertDontSee('Missing (app)')
|
||||||
|
->assertDontSee('raw-grant-id-must-not-render')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs();
|
||||||
|
});
|
||||||
@ -11,6 +11,8 @@
|
|||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||||
@ -41,6 +43,38 @@ function mockEnvironmentDashboardReadinessPermissions(array $overview = []): voi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configureTenantDashboardReadinessPermissionFixture(): void
|
||||||
|
{
|
||||||
|
config()->set('intune_permissions.permissions', [[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
]]);
|
||||||
|
config()->set('entra_permissions.permissions', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTenantDashboardReadinessPermissionEvidence(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
array $attributes = [],
|
||||||
|
): ManagedEnvironmentPermission {
|
||||||
|
return ManagedEnvironmentPermission::query()->create(array_replace([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'permission_key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'status' => 'granted',
|
||||||
|
'details' => [
|
||||||
|
'source' => 'graph_api',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
],
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
it('renders the recovery-readiness seam as a productization baseline', function (): void {
|
it('renders the recovery-readiness seam as a productization baseline', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
@ -160,8 +194,37 @@ function mockEnvironmentDashboardReadinessPermissions(array $overview = []): voi
|
|||||||
->not->toBeNull()
|
->not->toBeNull()
|
||||||
->and($providerHealth['headline'])->toBe('Microsoft Graph')
|
->and($providerHealth['headline'])->toBe('Microsoft Graph')
|
||||||
->and($providerHealth['status'])->toBe('Blocked')
|
->and($providerHealth['status'])->toBe('Blocked')
|
||||||
->and($providerHealth['body'])->toContain('2 application permission(s) are still missing.')
|
->and($providerHealth['body'])->toBe('Provider readiness is currently blocked for this environment.');
|
||||||
->and(collect($providerHealth['meta'])->firstWhere('label', 'Missing permissions')['value'] ?? null)->toBe('3');
|
});
|
||||||
|
|
||||||
|
it('does not show stale healthy provider verification as ready on the dashboard provider card', function (): void {
|
||||||
|
configureTenantDashboardReadinessPermissionFixture();
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
mockEnvironmentDashboardReadinessPermissions();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'last_health_check_at' => now()->subDays(31),
|
||||||
|
'display_name' => 'Microsoft Graph',
|
||||||
|
]);
|
||||||
|
createTenantDashboardReadinessPermissionEvidence($tenant, $connection);
|
||||||
|
|
||||||
|
$summary = app(EnvironmentDashboardSummaryBuilder::class)
|
||||||
|
->build($tenant, $user)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$providerHealth = collect($summary['readinessCards'])->firstWhere('key', 'provider_health');
|
||||||
|
|
||||||
|
expect($providerHealth)
|
||||||
|
->not->toBeNull()
|
||||||
|
->and($providerHealth['status'])->toBe('Verification expired')
|
||||||
|
->and($providerHealth['tone'])->toBe('warning')
|
||||||
|
->and($providerHealth['body'])->toContain('The verification snapshot is stale.')
|
||||||
|
->and($providerHealth['status'])->not->toBe('Ready')
|
||||||
|
->and($providerHealth['status'])->not->toBe('Healthy');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps readiness follow-up destinations tenant-scoped across review, internal evidence, output, and permissions surfaces', function (): void {
|
it('keeps readiness follow-up destinations tenant-scoped across review, internal evidence, output, and permissions surfaces', function (): void {
|
||||||
@ -333,7 +396,7 @@ function mockEnvironmentDashboardReadinessPermissions(array $overview = []): voi
|
|||||||
->and($providerHealth)
|
->and($providerHealth)
|
||||||
->not->toBeNull()
|
->not->toBeNull()
|
||||||
->and($providerHealth['status'])->toBe('Provider status unavailable')
|
->and($providerHealth['status'])->toBe('Provider status unavailable')
|
||||||
->and($providerHealth['body'])->toBe('No provider health snapshot is currently available for this environment.')
|
->and($providerHealth['body'])->toBe('No provider readiness snapshot is currently available for this environment.')
|
||||||
->and($providerHealth['actionUrl'])->toBe(RequiredPermissionsLinks::requiredPermissions($tenant))
|
->and($providerHealth['actionUrl'])->toBe(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||||
->and($outputCard)
|
->and($outputCard)
|
||||||
->not->toBeNull()
|
->not->toBeNull()
|
||||||
|
|||||||
@ -82,7 +82,7 @@ function mockEnvironmentDashboardSummaryPermissions(array $overview = []): void
|
|||||||
->assertSee('Operations needing attention')
|
->assertSee('Operations needing attention')
|
||||||
->assertSee('Current review')
|
->assertSee('Current review')
|
||||||
->assertSee('Risk exceptions')
|
->assertSee('Risk exceptions')
|
||||||
->assertSee('Provider Health')
|
->assertSee('Provider readiness')
|
||||||
->assertSee('Customer-safe output')
|
->assertSee('Customer-safe output')
|
||||||
->assertSee('Operations requiring attention')
|
->assertSee('Operations requiring attention')
|
||||||
->assertSee('Review operation')
|
->assertSee('Review operation')
|
||||||
|
|||||||
@ -39,6 +39,12 @@ function spec353RequiredPermissionsSeedRows(
|
|||||||
array $errorKeys = [],
|
array $errorKeys = [],
|
||||||
?string $lastCheckedAt = null,
|
?string $lastCheckedAt = null,
|
||||||
): void {
|
): void {
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('managed_environment_id', (int) $environment->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('is_default', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
||||||
if (! is_array($permission)) {
|
if (! is_array($permission)) {
|
||||||
continue;
|
continue;
|
||||||
@ -60,7 +66,13 @@ function spec353RequiredPermissionsSeedRows(
|
|||||||
'status' => in_array($permissionKey, $errorKeys, true)
|
'status' => in_array($permissionKey, $errorKeys, true)
|
||||||
? 'error'
|
? 'error'
|
||||||
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
||||||
'details' => ['source' => 'spec353-required-permissions-test'],
|
'details' => array_filter([
|
||||||
|
'source' => 'spec353-required-permissions-test',
|
||||||
|
'provider_connection_id' => $connection instanceof ProviderConnection ? (int) $connection->getKey() : null,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
], static fn (mixed $value): bool => $value !== null),
|
||||||
'last_checked_at' => $lastCheckedAt ? Carbon::parse($lastCheckedAt) : now(),
|
'last_checked_at' => $lastCheckedAt ? Carbon::parse($lastCheckedAt) : now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,12 +8,11 @@
|
|||||||
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ListManagedEnvironments;
|
||||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ListManagedEnvironments;
|
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
@ -57,7 +56,7 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
|
|||||||
expect($table->persistsSortInSession())->toBeTrue();
|
expect($table->persistsSortInSession())->toBeTrue();
|
||||||
expect($table->persistsFiltersInSession())->toBeTrue();
|
expect($table->persistsFiltersInSession())->toBeTrue();
|
||||||
expect($table->getEmptyStateHeading())->toBe('No tenants connected');
|
expect($table->getEmptyStateHeading())->toBe('No tenants connected');
|
||||||
expect($table->getEmptyStateDescription())->toBe('Add a tenant to start syncing inventory, policies, and provider health into this workspace.');
|
expect($table->getEmptyStateDescription())->toBe('Add a tenant to start syncing inventory, policies, and provider readiness into this workspace.');
|
||||||
expect($table->getColumn('name')?->isSearchable())->toBeTrue();
|
expect($table->getColumn('name')?->isSearchable())->toBeTrue();
|
||||||
expect($table->getColumn('name')?->isSortable())->toBeTrue();
|
expect($table->getColumn('name')?->isSortable())->toBeTrue();
|
||||||
expect($table->getColumn('managed_environment_id')?->isToggleable())->toBeTrue();
|
expect($table->getColumn('managed_environment_id')?->isToggleable())->toBeTrue();
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\ManagedEnvironmentPermission;
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
use App\Models\User;
|
use App\Models\ProviderConnection;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@ -35,19 +35,35 @@ function seedEnvironmentRequiredPermissionsFixture(ManagedEnvironment $tenant):
|
|||||||
]);
|
]);
|
||||||
config()->set('entra_permissions.permissions', []);
|
config()->set('entra_permissions.permissions', []);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
$details = [
|
||||||
|
'source' => 'fixture',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
];
|
||||||
|
|
||||||
ManagedEnvironmentPermission::query()->create([
|
ManagedEnvironmentPermission::query()->create([
|
||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'permission_key' => 'Group.Read.All',
|
'permission_key' => 'Group.Read.All',
|
||||||
'status' => 'missing',
|
'status' => 'missing',
|
||||||
'details' => ['source' => 'fixture'],
|
'details' => $details,
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ManagedEnvironmentPermission::query()->create([
|
ManagedEnvironmentPermission::query()->create([
|
||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'permission_key' => 'Reports.Read.All',
|
'permission_key' => 'Reports.Read.All',
|
||||||
'status' => 'granted',
|
'status' => 'granted',
|
||||||
'details' => ['source' => 'fixture'],
|
'details' => $details,
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -68,13 +84,13 @@ function seedEnvironmentRequiredPermissionsFixture(ManagedEnvironment $tenant):
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||||
'status' => 'present',
|
'status' => 'granted',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'search' => 'Reports',
|
'search' => 'Reports',
|
||||||
]))
|
]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Reports.Read.All')
|
->assertSee('Reports.Read.All')
|
||||||
->assertSee('1 permission(s) currently pass.');
|
->assertSee('1 permission(s) currently granted.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps copy payloads feature-scoped and shows the native no-matches state', function (): void {
|
it('keeps copy payloads feature-scoped and shows the native no-matches state', function (): void {
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('promotes consent and verification to the default-visible provider connection list axes', function (): void {
|
it('promotes consent and readiness to the default-visible provider connection list axes', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->create([
|
||||||
@ -71,8 +71,8 @@
|
|||||||
'Enabled',
|
'Enabled',
|
||||||
'Consent',
|
'Consent',
|
||||||
'Granted',
|
'Granted',
|
||||||
'Verification',
|
'Readiness',
|
||||||
'Degraded',
|
'Verification failed',
|
||||||
'Diagnostics',
|
'Diagnostics',
|
||||||
'Last error reason',
|
'Last error reason',
|
||||||
]);
|
]);
|
||||||
@ -101,7 +101,7 @@
|
|||||||
'Enabled',
|
'Enabled',
|
||||||
'Consent',
|
'Consent',
|
||||||
'Granted',
|
'Granted',
|
||||||
'Verification',
|
'Readiness',
|
||||||
'Blocked',
|
'Blocked',
|
||||||
'Diagnostics',
|
'Diagnostics',
|
||||||
'Last error reason',
|
'Last error reason',
|
||||||
@ -163,13 +163,13 @@
|
|||||||
->assertCanSeeTableRecords([$connection])
|
->assertCanSeeTableRecords([$connection])
|
||||||
->assertSee('Disabled')
|
->assertSee('Disabled')
|
||||||
->assertSee('Granted')
|
->assertSee('Granted')
|
||||||
->assertSee('Healthy')
|
->assertSee('Not configured')
|
||||||
->assertDontSee('Connected');
|
->assertDontSee('Connected');
|
||||||
|
|
||||||
$this->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant))
|
$this->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Disabled')
|
->assertSee('Disabled')
|
||||||
->assertSee('Granted')
|
->assertSee('Granted')
|
||||||
->assertSee('Healthy')
|
->assertSee('Not configured')
|
||||||
->assertDontSee('Connected');
|
->assertDontSee('Connected');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
->assertSee('Target scope ID')
|
->assertSee('Target scope ID')
|
||||||
->assertSee('Lifecycle')
|
->assertSee('Lifecycle')
|
||||||
->assertSee('Enabled')
|
->assertSee('Enabled')
|
||||||
->assertSee('Verification')
|
->assertSee('Readiness')
|
||||||
->assertSee('Migration review')
|
->assertSee('Migration review')
|
||||||
->assertSee('Review required')
|
->assertSee('Review required')
|
||||||
->assertDontSee('Entra tenant ID')
|
->assertDontSee('Entra tenant ID')
|
||||||
|
|||||||
@ -40,6 +40,12 @@ function spec353FeatureSeedPermissionRows(
|
|||||||
array $missingKeys = [],
|
array $missingKeys = [],
|
||||||
array $errorKeys = [],
|
array $errorKeys = [],
|
||||||
): void {
|
): void {
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('managed_environment_id', (int) $environment->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('is_default', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
||||||
if (! is_array($permission)) {
|
if (! is_array($permission)) {
|
||||||
continue;
|
continue;
|
||||||
@ -61,7 +67,13 @@ function spec353FeatureSeedPermissionRows(
|
|||||||
'status' => in_array($permissionKey, $errorKeys, true)
|
'status' => in_array($permissionKey, $errorKeys, true)
|
||||||
? 'error'
|
? 'error'
|
||||||
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
||||||
'details' => ['source' => 'spec353-feature-test'],
|
'details' => array_filter([
|
||||||
|
'source' => 'spec353-feature-test',
|
||||||
|
'provider_connection_id' => $connection instanceof ProviderConnection ? (int) $connection->getKey() : null,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
], static fn (mixed $value): bool => $value !== null),
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\ManagedEnvironmentResource;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||||
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function spec394ProductSanityConfigurePermissions(array $permissions = []): void
|
||||||
|
{
|
||||||
|
config()->set('intune_permissions.permissions', $permissions !== [] ? $permissions : [
|
||||||
|
[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'Group.Read.All',
|
||||||
|
'type' => 'delegated',
|
||||||
|
'description' => 'Read groups',
|
||||||
|
'features' => ['restore'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
config()->set('entra_permissions.permissions', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec394ProductSanityConnection(ManagedEnvironment $environment, array $attributes = []): ProviderConnection
|
||||||
|
{
|
||||||
|
return ProviderConnection::factory()
|
||||||
|
->platform()
|
||||||
|
->verifiedHealthy()
|
||||||
|
->create(array_replace([
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'display_name' => 'Spec394 Provider Connection',
|
||||||
|
'is_default' => true,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec394ProductSanityPermission(
|
||||||
|
ManagedEnvironment $environment,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
string $permissionKey,
|
||||||
|
string $status = 'granted',
|
||||||
|
array $details = [],
|
||||||
|
): ManagedEnvironmentPermission {
|
||||||
|
return ManagedEnvironmentPermission::query()->create([
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'permission_key' => $permissionKey,
|
||||||
|
'status' => $status,
|
||||||
|
'details' => array_replace([
|
||||||
|
'source' => 'spec394-product-sanity',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'grant_id' => 'raw-grant-id-must-not-render',
|
||||||
|
'access_token' => 'raw-token-must-not-render',
|
||||||
|
], $details),
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec394ProductSanityActAs($user, ManagedEnvironment $environment): void
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
$environment->makeCurrent();
|
||||||
|
Filament::setTenant($environment, true);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not render stale healthy provider connections as healthy or ready in list view or edit', function (): void {
|
||||||
|
spec394ProductSanityConfigurePermissions([[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
]]);
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$connection = spec394ProductSanityConnection($environment, [
|
||||||
|
'display_name' => 'Spec394 Stale Expired Connection',
|
||||||
|
'last_health_check_at' => now()->subDays(31),
|
||||||
|
]);
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'DeviceManagementApps.Read.All');
|
||||||
|
|
||||||
|
spec394ProductSanityActAs($user, $environment);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListProviderConnections::class)
|
||||||
|
->assertCanSeeTableRecords([$connection])
|
||||||
|
->assertTableColumnFormattedStateSet('verification_status', 'Verification expired', $connection);
|
||||||
|
|
||||||
|
$this->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $environment))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Verification expired')
|
||||||
|
->assertDontSeeText('Healthy');
|
||||||
|
|
||||||
|
$this->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $environment))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Verification expired')
|
||||||
|
->assertDontSeeText('Healthy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fresh verification with effective permissions as ready and keeps required counts canonical', function (): void {
|
||||||
|
spec394ProductSanityConfigurePermissions();
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$connection = spec394ProductSanityConnection($environment);
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'DeviceManagementApps.Read.All');
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'Group.Read.All');
|
||||||
|
|
||||||
|
spec394ProductSanityActAs($user, $environment);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListProviderConnections::class)
|
||||||
|
->assertCanSeeTableRecords([$connection])
|
||||||
|
->assertTableColumnFormattedStateSet('verification_status', 'Ready', $connection);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
||||||
|
->get(RequiredPermissionsLinks::requiredPermissions($environment))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('All required permissions are granted')
|
||||||
|
->assertSee('2 permission(s) currently granted.')
|
||||||
|
->assertSee('Out of 2 required permissions, 2 are currently granted.')
|
||||||
|
->assertSeeInOrder(['Required', '2'])
|
||||||
|
->assertSeeInOrder(['Granted', '2'])
|
||||||
|
->assertSeeInOrder(['Missing', '0'])
|
||||||
|
->assertSeeInOrder(['Blocked', '0'])
|
||||||
|
->assertSeeInOrder(['Expired', '0'])
|
||||||
|
->assertSeeInOrder(['Unknown', '0'])
|
||||||
|
->assertDontSee('Missing (app)')
|
||||||
|
->assertDontSee('Needs review')
|
||||||
|
->assertDontSee('Present 0')
|
||||||
|
->assertDontSee('raw-grant-id-must-not-render')
|
||||||
|
->assertDontSee('raw-token-must-not-render');
|
||||||
|
|
||||||
|
expect($response)->not->toBeNull();
|
||||||
|
|
||||||
|
$viewModel = app(ManagedEnvironmentRequiredPermissionsViewModelBuilder::class)
|
||||||
|
->build($environment, ['status' => 'all']);
|
||||||
|
$counts = data_get($viewModel, 'overview.counts', []);
|
||||||
|
|
||||||
|
expect($counts['required'] ?? null)->toBe(2)
|
||||||
|
->and(($counts['granted'] ?? 0) + ($counts['missing'] ?? 0) + ($counts['blocked'] ?? 0) + ($counts['expired'] ?? 0) + ($counts['unknown'] ?? 0))->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces missing application and delegated permissions with actionable required-permissions copy', function (): void {
|
||||||
|
spec394ProductSanityConfigurePermissions();
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$connection = spec394ProductSanityConnection($environment);
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'DeviceManagementApps.Read.All', 'missing');
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'Group.Read.All', 'missing');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
||||||
|
->get(RequiredPermissionsLinks::requiredPermissions($environment, ['status' => 'all']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Missing application permissions')
|
||||||
|
->assertSee('Missing delegated permissions')
|
||||||
|
->assertSeeInOrder(['Required', '2'])
|
||||||
|
->assertSeeInOrder(['Granted', '0'])
|
||||||
|
->assertSeeInOrder(['Missing', '2'])
|
||||||
|
->assertSeeInOrder(['Blocked', '0'])
|
||||||
|
->assertSeeInOrder(['Expired', '0'])
|
||||||
|
->assertSeeInOrder(['Unknown', '0'])
|
||||||
|
->assertDontSee('Missing (app)')
|
||||||
|
->assertDontSee('Needs review')
|
||||||
|
->assertSee('Open admin consent')
|
||||||
|
->assertDontSee('Present');
|
||||||
|
|
||||||
|
$viewModel = app(ManagedEnvironmentRequiredPermissionsViewModelBuilder::class)
|
||||||
|
->build($environment, ['status' => 'all']);
|
||||||
|
$counts = data_get($viewModel, 'overview.counts', []);
|
||||||
|
|
||||||
|
expect($counts['required'] ?? null)->toBe(2)
|
||||||
|
->and($counts['missing_application'] ?? null)->toBe(1)
|
||||||
|
->and($counts['missing_delegated'] ?? null)->toBe(1)
|
||||||
|
->and(($counts['granted'] ?? 0) + ($counts['missing'] ?? 0) + ($counts['blocked'] ?? 0) + ($counts['expired'] ?? 0) + ($counts['unknown'] ?? 0))->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders admin-consent denied provider connections as blocked', function (): void {
|
||||||
|
spec394ProductSanityConfigurePermissions([[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
]]);
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$connection = spec394ProductSanityConnection($environment, [
|
||||||
|
'consent_status' => ProviderConsentStatus::Failed->value,
|
||||||
|
'consent_error_code' => 'admin_consent_denied',
|
||||||
|
]);
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'DeviceManagementApps.Read.All');
|
||||||
|
|
||||||
|
spec394ProductSanityActAs($user, $environment);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListProviderConnections::class)
|
||||||
|
->assertCanSeeTableRecords([$connection])
|
||||||
|
->assertTableColumnFormattedStateSet('verification_status', 'Blocked', $connection)
|
||||||
|
->assertDontSee('Ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show provider management guidance actions to readonly users', function (): void {
|
||||||
|
spec394ProductSanityConfigurePermissions([[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
]]);
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$connection = spec394ProductSanityConnection($environment, [
|
||||||
|
'display_name' => 'Spec394 Readonly Stale Connection',
|
||||||
|
'last_health_check_at' => now()->subDays(31),
|
||||||
|
]);
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'DeviceManagementApps.Read.All');
|
||||||
|
|
||||||
|
spec394ProductSanityActAs($user, $environment);
|
||||||
|
|
||||||
|
$this->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $environment))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Verification expired')
|
||||||
|
->assertSee('Open required permissions')
|
||||||
|
->assertDontSee('Run provider verification')
|
||||||
|
->assertDontSee('Edit provider connection')
|
||||||
|
->assertDontSee('Set as default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show admin-consent remediation on required permissions to readonly users', function (): void {
|
||||||
|
spec394ProductSanityConfigurePermissions([[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
]]);
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$connection = spec394ProductSanityConnection($environment);
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'DeviceManagementApps.Read.All', 'missing');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
||||||
|
->get(RequiredPermissionsLinks::requiredPermissions($environment))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Copy missing application permissions')
|
||||||
|
->assertSee('Copy missing permissions and hand them')
|
||||||
|
->assertDontSee('Open admin consent')
|
||||||
|
->assertDontSee('Admin consent guide')
|
||||||
|
->assertDontSee('Run provider verification');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let wrong-scope grants render as granted in managed-environment provider panels', function (): void {
|
||||||
|
spec394ProductSanityConfigurePermissions([[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
]]);
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$connection = spec394ProductSanityConnection($environment);
|
||||||
|
spec394ProductSanityPermission($environment, $connection, 'DeviceManagementApps.Read.All', 'granted', [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey() + 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec394ProductSanityActAs($user, $environment);
|
||||||
|
|
||||||
|
$method = new ReflectionMethod(ManagedEnvironmentResource::class, 'storedPermissionSnapshot');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
$snapshot = $method->invoke(null, $environment);
|
||||||
|
|
||||||
|
expect($snapshot)->toHaveCount(1)
|
||||||
|
->and($snapshot[0]['status'])->toBe('unknown')
|
||||||
|
->and($snapshot[0]['status'])->not->toBe('granted');
|
||||||
|
});
|
||||||
@ -3,8 +3,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ManagedEnvironmentPermission;
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator;
|
use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator;
|
||||||
use App\Support\Providers\Capabilities\ProviderCapabilityStatus;
|
use App\Support\Providers\Capabilities\ProviderCapabilityStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
@ -26,6 +26,24 @@ function spec283ConfiguredPermissionRows(): array
|
|||||||
if (! function_exists('spec283SeedRequirementRows')) {
|
if (! function_exists('spec283SeedRequirementRows')) {
|
||||||
function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requirementKeys, array $missingKeys = [], array $errorKeys = []): void
|
function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requirementKeys, array $missingKeys = [], array $errorKeys = []): void
|
||||||
{
|
{
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$details = ['source' => 'spec-283-test'];
|
||||||
|
|
||||||
|
if ($connection instanceof ProviderConnection) {
|
||||||
|
$details = array_merge($details, [
|
||||||
|
'workspace_id' => is_numeric($connection->workspace_id) ? (int) $connection->workspace_id : null,
|
||||||
|
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
||||||
if (! is_array($permission)) {
|
if (! is_array($permission)) {
|
||||||
continue;
|
continue;
|
||||||
@ -49,7 +67,7 @@ function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requireme
|
|||||||
'status' => in_array($permissionKey, $errorKeys, true)
|
'status' => in_array($permissionKey, $errorKeys, true)
|
||||||
? 'error'
|
? 'error'
|
||||||
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
||||||
'details' => ['source' => 'spec-283-test'],
|
'details' => $details,
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,15 +2,33 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\EnvironmentRequiredPermissions;
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\ManagedEnvironmentPermission;
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
function spec394TrustedStatePermissionDetails(ManagedEnvironment $tenant): array
|
||||||
|
{
|
||||||
|
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'source' => 'db',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
|
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
@ -49,16 +67,17 @@
|
|||||||
|
|
||||||
ManagedEnvironmentPermission::create([
|
ManagedEnvironmentPermission::create([
|
||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'permission_key' => 'ManagedEnvironment.Read.All',
|
'permission_key' => 'ManagedEnvironment.Read.All',
|
||||||
'status' => 'granted',
|
'status' => 'granted',
|
||||||
'details' => ['source' => 'db'],
|
'details' => spec394TrustedStatePermissionDetails($tenant),
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||||
'status' => 'present',
|
'status' => 'granted',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'search' => 'ManagedEnvironment',
|
'search' => 'ManagedEnvironment',
|
||||||
]))
|
]))
|
||||||
@ -120,9 +139,10 @@
|
|||||||
|
|
||||||
ManagedEnvironmentPermission::query()->create([
|
ManagedEnvironmentPermission::query()->create([
|
||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'permission_key' => 'ManagedEnvironment.Read.All',
|
'permission_key' => 'ManagedEnvironment.Read.All',
|
||||||
'status' => 'granted',
|
'status' => 'granted',
|
||||||
'details' => ['source' => 'db'],
|
'details' => spec394TrustedStatePermissionDetails($tenant),
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -131,7 +151,7 @@
|
|||||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||||
'tenant' => $tenant->external_id,
|
'tenant' => $tenant->external_id,
|
||||||
'managed_environment_id' => (string) $otherTenant->getKey(),
|
'managed_environment_id' => (string) $otherTenant->getKey(),
|
||||||
'status' => 'present',
|
'status' => 'granted',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'features' => ['backup'],
|
'features' => ['backup'],
|
||||||
'search' => 'ManagedEnvironment',
|
'search' => 'ManagedEnvironment',
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\ManagedEnvironmentPermission;
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
@ -31,27 +32,44 @@
|
|||||||
]);
|
]);
|
||||||
config()->set('entra_permissions.permissions', []);
|
config()->set('entra_permissions.permissions', []);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
$details = [
|
||||||
|
'source' => 'db',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
];
|
||||||
|
|
||||||
ManagedEnvironmentPermission::create([
|
ManagedEnvironmentPermission::create([
|
||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'permission_key' => 'Alpha.Read.All',
|
'permission_key' => 'Alpha.Read.All',
|
||||||
'status' => 'granted',
|
'status' => 'granted',
|
||||||
'details' => ['source' => 'db'],
|
'details' => $details,
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ManagedEnvironmentPermission::create([
|
ManagedEnvironmentPermission::create([
|
||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'permission_key' => 'Beta.Read.All',
|
'permission_key' => 'Beta.Read.All',
|
||||||
'status' => 'granted',
|
'status' => 'granted',
|
||||||
'details' => ['source' => 'db'],
|
'details' => $details,
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ManagedEnvironmentPermission::create([
|
ManagedEnvironmentPermission::create([
|
||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'permission_key' => 'Gamma.Manage.All',
|
'permission_key' => 'Gamma.Manage.All',
|
||||||
'status' => 'granted',
|
'status' => 'granted',
|
||||||
'details' => ['source' => 'db'],
|
'details' => $details,
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -61,13 +79,13 @@
|
|||||||
|
|
||||||
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('All required permissions are present')
|
->assertSee('All required permissions are granted')
|
||||||
->assertDontSee('Alpha.Read.All')
|
->assertDontSee('Alpha.Read.All')
|
||||||
->assertDontSee('Beta.Read.All')
|
->assertDontSee('Beta.Read.All')
|
||||||
->assertDontSee('Gamma.Manage.All');
|
->assertDontSee('Gamma.Manage.All');
|
||||||
|
|
||||||
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||||
'status' => 'present',
|
'status' => 'granted',
|
||||||
]))
|
]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Alpha.Read.All')
|
->assertSee('Alpha.Read.All')
|
||||||
@ -75,7 +93,7 @@
|
|||||||
->assertSee('Gamma.Manage.All');
|
->assertSee('Gamma.Manage.All');
|
||||||
|
|
||||||
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||||
'status' => 'present',
|
'status' => 'granted',
|
||||||
'type' => 'delegated',
|
'type' => 'delegated',
|
||||||
]))
|
]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Support\ManagedEnvironmentLinks;
|
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
use App\Support\ManagedEnvironmentLinks;
|
||||||
|
|
||||||
it('renders no-data navigation and next-step links using canonical manage surfaces only', function (): void {
|
it('renders no-data navigation and next-step links using canonical manage surfaces only', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
@ -24,9 +24,9 @@
|
|||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSeeInOrder(['Summary', 'Issues', 'Passed', 'Technical details'])
|
->assertSeeInOrder(['Summary', 'Issues', 'Granted', 'Technical details'])
|
||||||
->assertSee('data-testid="technical-details"', false)
|
->assertSee('data-testid="technical-details"', false)
|
||||||
->assertDontSee('data-testid="technical-details" open', false);
|
->assertDontSee('data-testid="technical-details" open', false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,12 +5,12 @@
|
|||||||
use App\Filament\System\Pages\Dashboard;
|
use App\Filament\System\Pages\Dashboard;
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
use App\Filament\System\Widgets\CustomerHealthKpis;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ProductUsageEvent;
|
use App\Models\ProductUsageEvent;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\ManagedEnvironment;
|
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -55,7 +55,7 @@
|
|||||||
expect((int) $stats['Healthy']['value'] - (int) $baselineStats['Healthy']['value'])->toBe(1)
|
expect((int) $stats['Healthy']['value'] - (int) $baselineStats['Healthy']['value'])->toBe(1)
|
||||||
->and($stats['Healthy']['description'])->toBe('Operational stability, review-pack readiness, and engagement freshness honor Last 24 hours.')
|
->and($stats['Healthy']['description'])->toBe('Operational stability, review-pack readiness, and engagement freshness honor Last 24 hours.')
|
||||||
->and((int) $stats['Warning']['value'] - (int) $baselineStats['Warning']['value'])->toBe(1)
|
->and((int) $stats['Warning']['value'] - (int) $baselineStats['Warning']['value'])->toBe(1)
|
||||||
->and($stats['Warning']['description'])->toBe('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
|
->and($stats['Warning']['description'])->toBe('Onboarding readiness, provider readiness, and governance pressure stay point-in-time.')
|
||||||
->and((int) $stats['Critical']['value'] - (int) $baselineStats['Critical']['value'])->toBe(1)
|
->and((int) $stats['Critical']['value'] - (int) $baselineStats['Critical']['value'])->toBe(1)
|
||||||
->and((int) $stats['Unknown']['value'] - (int) $baselineStats['Unknown']['value'])->toBe(1)
|
->and((int) $stats['Unknown']['value'] - (int) $baselineStats['Unknown']['value'])->toBe(1)
|
||||||
->and($stats['Unknown']['description'])->toBe('Missing or stale inputs stay explicit instead of silently reading healthy.');
|
->and($stats['Unknown']['description'])->toBe('Missing or stale inputs stay explicit instead of silently reading healthy.');
|
||||||
|
|||||||
@ -7,17 +7,17 @@
|
|||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
|
||||||
it('renders provider health context on the canonical environment dashboard', function (): void {
|
it('renders provider readiness context on the canonical environment dashboard', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
|
->get(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Environment governance overview')
|
->assertSee('Environment governance overview')
|
||||||
->assertSee('Provider Health');
|
->assertSee('Provider readiness');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps provider health context when the environment needs a default microsoft connection', function (): void {
|
it('keeps provider readiness context when the environment needs a default microsoft connection', function (): void {
|
||||||
$tenant = \App\Models\ManagedEnvironment::factory()->active()->create();
|
$tenant = \App\Models\ManagedEnvironment::factory()->active()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(
|
[$user, $tenant] = createUserWithTenant(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -37,10 +37,10 @@
|
|||||||
->get(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
|
->get(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Environment governance overview')
|
->assertSee('Environment governance overview')
|
||||||
->assertSee('Provider Health');
|
->assertSee('Provider readiness');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the environment dashboard provider health summary from the default connection posture', function (): void {
|
it('renders the environment dashboard provider readiness summary from the default connection posture', function (): void {
|
||||||
$tenant = \App\Models\ManagedEnvironment::factory()->active()->create();
|
$tenant = \App\Models\ManagedEnvironment::factory()->active()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(
|
[$user, $tenant] = createUserWithTenant(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -62,9 +62,10 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
|
->get(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Provider Health')
|
->assertSee('Provider readiness')
|
||||||
->assertSee('Disabled')
|
->assertSee('Disabled')
|
||||||
->assertSee('Healthy')
|
->assertSee('Not configured')
|
||||||
|
->assertDontSee('Healthy')
|
||||||
->assertDontSee('Connected')
|
->assertDontSee('Connected')
|
||||||
->assertDontSee('Legacy health');
|
->assertDontSee('Legacy health');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1138,6 +1138,24 @@ function spec283ConfiguredPermissionRows(): array
|
|||||||
if (! function_exists('spec283SeedRequirementRows')) {
|
if (! function_exists('spec283SeedRequirementRows')) {
|
||||||
function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requirementKeys, array $missingKeys = [], array $errorKeys = []): void
|
function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requirementKeys, array $missingKeys = [], array $errorKeys = []): void
|
||||||
{
|
{
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$details = ['source' => 'spec-283-test'];
|
||||||
|
|
||||||
|
if ($connection instanceof ProviderConnection) {
|
||||||
|
$details = array_merge($details, [
|
||||||
|
'workspace_id' => is_numeric($connection->workspace_id) ? (int) $connection->workspace_id : null,
|
||||||
|
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
||||||
if (! is_array($permission)) {
|
if (! is_array($permission)) {
|
||||||
continue;
|
continue;
|
||||||
@ -1161,7 +1179,7 @@ function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requireme
|
|||||||
'status' => in_array($permissionKey, $errorKeys, true)
|
'status' => in_array($permissionKey, $errorKeys, true)
|
||||||
? 'error'
|
? 'error'
|
||||||
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
||||||
'details' => ['source' => 'spec-283-test'],
|
'details' => $details,
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\ManagedEnvironmentPermissionService;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessResolver;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessState;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function spec394ConfigureRequiredPermission(): void
|
||||||
|
{
|
||||||
|
config()->set('intune_permissions.permissions', [[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Read Intune apps',
|
||||||
|
'features' => ['backup'],
|
||||||
|
]]);
|
||||||
|
config()->set('entra_permissions.permissions', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec394HealthyConnection(ManagedEnvironment $environment, array $attributes = []): ProviderConnection
|
||||||
|
{
|
||||||
|
return ProviderConnection::factory()
|
||||||
|
->platform()
|
||||||
|
->verifiedHealthy()
|
||||||
|
->create(array_replace([
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec394PermissionEvidence(ManagedEnvironment $environment, ProviderConnection $connection, array $attributes = []): ManagedEnvironmentPermission
|
||||||
|
{
|
||||||
|
return ManagedEnvironmentPermission::query()->create(array_replace([
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'permission_key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'status' => 'granted',
|
||||||
|
'details' => [
|
||||||
|
'source' => 'graph_api',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
],
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('marks a provider ready only when granted evidence matches the current provider connection', function (): void {
|
||||||
|
spec394ConfigureRequiredPermission();
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec394HealthyConnection($environment);
|
||||||
|
spec394PermissionEvidence($environment, $connection);
|
||||||
|
|
||||||
|
$result = app(ProviderReadinessResolver::class)->forConnection($connection, $user);
|
||||||
|
|
||||||
|
expect($result->state)->toBe(ProviderReadinessState::Ready)
|
||||||
|
->and($result->counts['granted'])->toBe(1)
|
||||||
|
->and($result->counts['required'])->toBe(1)
|
||||||
|
->and($result->permissionRows[0]['is_effective'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat legacy unscoped permission rows as granted readiness evidence', function (): void {
|
||||||
|
spec394ConfigureRequiredPermission();
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec394HealthyConnection($environment);
|
||||||
|
spec394PermissionEvidence($environment, $connection, [
|
||||||
|
'details' => ['source' => 'legacy-fixture'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(ProviderReadinessResolver::class)->forConnection($connection, $user);
|
||||||
|
|
||||||
|
expect($result->state)->toBe(ProviderReadinessState::Unknown)
|
||||||
|
->and($result->counts['granted'])->toBe(0)
|
||||||
|
->and($result->counts['unknown'])->toBe(1)
|
||||||
|
->and($result->permissionRows[0]['reason_code'])->toBe('provider_permission_evidence_scope_mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat provider-connection-only permission rows as scoped readiness evidence', function (): void {
|
||||||
|
spec394ConfigureRequiredPermission();
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec394HealthyConnection($environment);
|
||||||
|
spec394PermissionEvidence($environment, $connection, [
|
||||||
|
'details' => [
|
||||||
|
'source' => 'partial-provider-evidence',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(ProviderReadinessResolver::class)->forConnection($connection, $user);
|
||||||
|
|
||||||
|
expect($result->state)->toBe(ProviderReadinessState::Unknown)
|
||||||
|
->and($result->counts['granted'])->toBe(0)
|
||||||
|
->and($result->counts['unknown'])->toBe(1)
|
||||||
|
->and($result->permissionRows[0]['reason_code'])->toBe('provider_permission_evidence_scope_mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires matched grants when the provider verification is stale', function (): void {
|
||||||
|
spec394ConfigureRequiredPermission();
|
||||||
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec394HealthyConnection($environment, [
|
||||||
|
'last_health_check_at' => now()->subDays(31),
|
||||||
|
]);
|
||||||
|
spec394PermissionEvidence($environment, $connection);
|
||||||
|
|
||||||
|
$result = app(ProviderReadinessResolver::class)->forConnection($connection, $user);
|
||||||
|
|
||||||
|
expect($result->state)->toBe(ProviderReadinessState::Expired)
|
||||||
|
->and($result->counts['expired'])->toBe(1)
|
||||||
|
->and($result->counts['granted'])->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists provider connection metadata when live permission checks refresh stored evidence', function (): void {
|
||||||
|
spec394ConfigureRequiredPermission();
|
||||||
|
[, $environment] = createUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec394HealthyConnection($environment);
|
||||||
|
|
||||||
|
$graph = Mockery::mock(GraphClientInterface::class);
|
||||||
|
$graph->shouldReceive('getServicePrincipalPermissions')
|
||||||
|
->once()
|
||||||
|
->andReturn(new GraphResponse(true, data: [
|
||||||
|
'permissions' => ['DeviceManagementApps.Read.All'],
|
||||||
|
]));
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, $graph);
|
||||||
|
|
||||||
|
app(ManagedEnvironmentPermissionService::class)->compare(
|
||||||
|
$environment,
|
||||||
|
persist: true,
|
||||||
|
liveCheck: true,
|
||||||
|
useConfiguredStub: false,
|
||||||
|
graphOptions: ['client_id' => 'app-client-id'],
|
||||||
|
providerConnection: $connection,
|
||||||
|
);
|
||||||
|
|
||||||
|
$stored = ManagedEnvironmentPermission::query()
|
||||||
|
->where('managed_environment_id', (int) $environment->getKey())
|
||||||
|
->where('permission_key', 'DeviceManagementApps.Read.All')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($stored)->not->toBeNull()
|
||||||
|
->and($stored->details['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||||
|
->and($stored->details['workspace_id'] ?? null)->toBe((int) $environment->workspace_id)
|
||||||
|
->and($stored->details['managed_environment_id'] ?? null)->toBe((int) $environment->getKey())
|
||||||
|
->and($stored->details['provider'] ?? null)->toBe('microsoft')
|
||||||
|
->and($stored->details['app_id'] ?? null)->toBe('app-client-id');
|
||||||
|
});
|
||||||
@ -30,7 +30,7 @@ function mockSpec353PermissionOverview(array $overview = []): void
|
|||||||
'counts' => [
|
'counts' => [
|
||||||
'missing_application' => 0,
|
'missing_application' => 0,
|
||||||
'missing_delegated' => 0,
|
'missing_delegated' => 0,
|
||||||
'present' => 12,
|
'granted' => 12,
|
||||||
'error' => 0,
|
'error' => 0,
|
||||||
],
|
],
|
||||||
'freshness' => [
|
'freshness' => [
|
||||||
@ -92,7 +92,7 @@ function mockSpec353PermissionOverview(array $overview = []): void
|
|||||||
mockSpec353PermissionOverview([
|
mockSpec353PermissionOverview([
|
||||||
'counts' => [
|
'counts' => [
|
||||||
'missing_application' => 2,
|
'missing_application' => 2,
|
||||||
'present' => 10,
|
'granted' => 10,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -130,6 +130,39 @@ function mockSpec353PermissionOverview(array $overview = []): void
|
|||||||
->and((string) data_get($case, 'primary_action.label'))->toBe(__('localization.provider_guidance.action_run_provider_verification'));
|
->and((string) data_get($case, 'primary_action.label'))->toBe(__('localization.provider_guidance.action_run_provider_verification'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not describe stale healthy verification details as ready', function (): void {
|
||||||
|
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'last_health_check_at' => now()->subDays(31),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockSpec353PermissionOverview([
|
||||||
|
'counts' => [
|
||||||
|
'granted' => 12,
|
||||||
|
'expired' => 1,
|
||||||
|
],
|
||||||
|
'freshness' => [
|
||||||
|
'last_refreshed_at' => now()->subDays(31)->toIso8601String(),
|
||||||
|
'is_stale' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$case = app(ProviderReadinessResolutionAdapter::class)
|
||||||
|
->forConnection($environment, $connection, ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_VIEW);
|
||||||
|
$technicalValues = collect(data_get($case, 'technical_details', []))
|
||||||
|
->pluck('value')
|
||||||
|
->map(static fn (mixed $value): string => (string) $value)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($case['key'])->toBe('provider_readiness.verification_required')
|
||||||
|
->and($technicalValues)->toContain(__('localization.provider_guidance.verification_ready_detail'))
|
||||||
|
->and($technicalValues)->not->toContain('Ready');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns verification-failed guidance with an operation link when a failed run exists', function (): void {
|
it('returns verification-failed guidance with an operation link when a failed run exists', function (): void {
|
||||||
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'description' => 'Gamma restore permission',
|
'description' => 'Gamma restore permission',
|
||||||
'features' => ['backup', 'restore'],
|
'features' => ['backup', 'restore'],
|
||||||
'status' => 'error',
|
'status' => 'unknown',
|
||||||
'details' => null,
|
'details' => null,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@ -59,16 +59,16 @@
|
|||||||
'gamma.manage',
|
'gamma.manage',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$presentDelegatedState = ManagedEnvironmentRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
$grantedDelegatedState = ManagedEnvironmentRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||||
'status' => 'present',
|
'status' => 'granted',
|
||||||
'type' => 'delegated',
|
'type' => 'delegated',
|
||||||
'features' => [],
|
'features' => [],
|
||||||
'search' => '',
|
'search' => '',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$presentDelegated = ManagedEnvironmentRequiredPermissionsViewModelBuilder::applyFilterState($permissions, $presentDelegatedState);
|
$grantedDelegated = ManagedEnvironmentRequiredPermissionsViewModelBuilder::applyFilterState($permissions, $grantedDelegatedState);
|
||||||
|
|
||||||
expect(array_column($presentDelegated, 'key'))->toBe([
|
expect(array_column($grantedDelegated, 'key'))->toBe([
|
||||||
'beta.read',
|
'beta.read',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
'counts' => [
|
'counts' => [
|
||||||
'missing_application' => 1,
|
'missing_application' => 1,
|
||||||
'missing_delegated' => 1,
|
'missing_delegated' => 1,
|
||||||
'present' => 3,
|
'granted' => 3,
|
||||||
'error' => 0,
|
'error' => 0,
|
||||||
],
|
],
|
||||||
'freshness' => [
|
'freshness' => [
|
||||||
@ -140,7 +140,10 @@
|
|||||||
'counts' => [
|
'counts' => [
|
||||||
'missing_application' => 1,
|
'missing_application' => 1,
|
||||||
'missing_delegated' => 1,
|
'missing_delegated' => 1,
|
||||||
'present' => 3,
|
'granted' => 3,
|
||||||
|
'blocked' => 0,
|
||||||
|
'expired' => 0,
|
||||||
|
'unknown' => 0,
|
||||||
'error' => 0,
|
'error' => 0,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
@ -204,7 +207,7 @@
|
|||||||
'counts' => [
|
'counts' => [
|
||||||
'missing_application' => 0,
|
'missing_application' => 0,
|
||||||
'missing_delegated' => 0,
|
'missing_delegated' => 0,
|
||||||
'present' => 15,
|
'granted' => 15,
|
||||||
'error' => 0,
|
'error' => 0,
|
||||||
],
|
],
|
||||||
'freshness' => [
|
'freshness' => [
|
||||||
@ -266,7 +269,7 @@
|
|||||||
'counts' => [
|
'counts' => [
|
||||||
'missing_application' => 0,
|
'missing_application' => 0,
|
||||||
'missing_delegated' => 0,
|
'missing_delegated' => 0,
|
||||||
'present' => 14,
|
'granted' => 14,
|
||||||
'error' => 1,
|
'error' => 1,
|
||||||
],
|
],
|
||||||
'freshness' => [
|
'freshness' => [
|
||||||
|
|||||||
414
specs/394-provider-freshness-permission-semantics/plan.md
Normal file
414
specs/394-provider-freshness-permission-semantics/plan.md
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
# Implementation Plan: Spec 394 - Provider Freshness & Permission Semantics v1
|
||||||
|
|
||||||
|
**Branch**: `394-provider-freshness-permission-semantics` | **Date**: 2026-06-21 | **Spec**: `specs/394-provider-freshness-permission-semantics/spec.md`
|
||||||
|
**Input**: Feature specification from `specs/394-provider-freshness-permission-semantics/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Create or consolidate one canonical provider readiness resolver that owns provider connection state, verification freshness, required permission coverage, row state, count invariants, recommended action, and capability-aware action visibility. Replace visible local provider health/readiness/count logic in Provider Connections, Required Permissions, Environment Dashboard, Workspace Overview, Governance Inbox, and direct downstream readiness consumers where repo-real.
|
||||||
|
|
||||||
|
This is a clean-cut correctness fix. Do not preserve `Healthy` while stale, `Present 0`, connected-as-ready, wrong-scope grants, stale-grant coverage, or compatibility shims.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1
|
||||||
|
**Storage**: PostgreSQL. No migration expected by default; use existing `provider_connections`, `managed_environment_permissions`, and verification `OperationRun`/report context first.
|
||||||
|
**Testing**: Pest 4 Unit, Feature, Filament/Livewire, bounded Browser smoke
|
||||||
|
**Validation Lanes**: fast-feedback, confidence, browser; PostgreSQL only if schema/query features require it
|
||||||
|
**Target Platform**: Laravel Sail local; Dokploy container deployment for staging/production
|
||||||
|
**Project Type**: Laravel monolith under `apps/platform`
|
||||||
|
**Performance Goals**: Resolver decisions must be DB-only during render, deterministic, scoped, and avoid external calls
|
||||||
|
**Constraints**: no Graph calls during render, no UI expansion, no raw provider payload in default UI, no legacy aliases/shims, no new dashboard/onboarding wizard
|
||||||
|
**Scale/Scope**: existing provider, permission, dashboard, overview, inbox, and readiness consumers
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
1. Inventory all provider readiness, provider health, verification freshness, required permission count, grant, capability, and downstream readiness selectors.
|
||||||
|
2. Define one canonical provider readiness resolver/result contract over existing provider connection, permission, and verification evidence.
|
||||||
|
3. Prove resolver behavior first with unit tests for fresh, stale, failed, unknown, missing, blocked, wrong-scope, stale-grant, and count-invariant cases.
|
||||||
|
4. Replace local visible state and count derivation in affected product surfaces.
|
||||||
|
5. Remove or delegate old local provider health/readiness helpers.
|
||||||
|
6. Replace ambiguous UI labels such as `Present` with canonical labels.
|
||||||
|
7. Enforce RBAC and technical-detail gating through existing capability/policy patterns.
|
||||||
|
8. Update affected fixtures/tests to assert canonical behavior.
|
||||||
|
9. Run focused browser smoke and artifact close-out checks.
|
||||||
|
|
||||||
|
Preferred shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProviderConnection + ManagedEnvironmentPermission + verification OperationRun/report
|
||||||
|
-> ProviderReadinessResolver
|
||||||
|
-> ProviderReadinessResult + PermissionCoverageRows
|
||||||
|
-> Filament pages/resources, dashboard/overview/inbox summaries, tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not create a generalized multi-provider framework, onboarding workflow, or persisted readiness ledger.
|
||||||
|
|
||||||
|
## Likely Affected Repository Surfaces
|
||||||
|
|
||||||
|
Implementation must re-verify exact current code before editing. Likely surfaces are:
|
||||||
|
|
||||||
|
- `apps/platform/app/Models/ProviderConnection.php`
|
||||||
|
- `apps/platform/app/Models/ManagedEnvironmentPermission.php`
|
||||||
|
- `apps/platform/app/Services/Intune/ManagedEnvironmentPermissionService.php`
|
||||||
|
- `apps/platform/app/Services/Intune/ManagedEnvironmentRequiredPermissionsViewModelBuilder.php`
|
||||||
|
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`
|
||||||
|
- `apps/platform/app/Support/ResolutionGuidance/Adapters/ProviderReadinessResolutionAdapter.php`
|
||||||
|
- `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php`
|
||||||
|
- `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityResult.php`
|
||||||
|
- `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityStatus.php`
|
||||||
|
- `apps/platform/app/Support/Providers/ProviderVerificationStatus.php`
|
||||||
|
- `apps/platform/app/Support/Badges/Domains/ProviderVerificationStatusBadge.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`
|
||||||
|
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php`
|
||||||
|
- `apps/platform/resources/views/filament/pages/environment-required-permissions.blade.php`
|
||||||
|
- `apps/platform/resources/views/filament/partials/provider-readiness-guidance-card.blade.php`
|
||||||
|
- `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php`
|
||||||
|
- `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||||
|
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
|
||||||
|
- `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewReadinessGate.php`
|
||||||
|
- `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationReadinessEvaluator.php`
|
||||||
|
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php`
|
||||||
|
- `apps/platform/app/Support/RestoreReadinessResolution/RestoreReadinessResolver.php`
|
||||||
|
- `apps/platform/app/Support/Baselines/Readiness/BaselineEvidenceReadinessDeriver.php`
|
||||||
|
- provider/required-permission/verification tests under `apps/platform/tests/Unit`, `apps/platform/tests/Feature`, and `apps/platform/tests/Browser`
|
||||||
|
|
||||||
|
Implementation may remove items from the touched list if repo truth proves they are not product-facing or already consume the canonical resolver safely.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: existing provider readiness, permission counts/rows, and readiness-blocker displays.
|
||||||
|
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: Provider Connections list/view/edit, Required Permissions, Environment Dashboard provider guidance, Workspace Overview provider/readiness signals, Governance Inbox provider-related items if present, direct downstream readiness summaries.
|
||||||
|
- **No-impact class, if applicable**: N/A.
|
||||||
|
- **Native vs custom classification summary**: mixed existing native Filament resources/pages plus existing Blade summary components.
|
||||||
|
- **Shared-family relevance**: status messaging, action links, dashboard signals, readiness cards/summaries, diagnostic disclosure, provider operation start links.
|
||||||
|
- **State layers in scope**: page, table, detail, action visibility/disabled state, dashboard summary, overview/inbox aggregation.
|
||||||
|
- **Audience modes in scope**: operator-MSP, workspace owner/manager, readonly authorized users, support/platform where authorized.
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: readiness decision first, diagnostics second, raw/support detail gated and collapsed.
|
||||||
|
- **Raw/support gating plan**: no raw provider payload or raw permission JSON by default; technical detail requires existing technical/manage capability.
|
||||||
|
- **One-primary-action / duplicate-truth control**: one resolver state and one primary action per focused provider readiness area.
|
||||||
|
- **Handling modes by drift class or surface**: stale-as-healthy is hard-stop; `Present 0` is hard-stop; wrong-scope grant coverage is hard-stop; secondary-copy polish is review-mandatory.
|
||||||
|
- **Repository-signal treatment**: completed Specs 281, 283, 294, 339, 353, and 381 are context only and must not be rewritten.
|
||||||
|
- **Special surface test profiles**: provider/integration strategic surface, diagnostic matrix page, dashboard/overview readiness signal, queue summary.
|
||||||
|
- **Required tests or manual smoke**: Unit resolver tests, Feature/Filament tests, one focused Browser smoke.
|
||||||
|
- **Exception path and spread control**: any remaining local provider logic must be documented in the implementation report with reason and follow-up.
|
||||||
|
- **Active feature PR close-out entry**: Provider Freshness / Permission Semantics Guardrail.
|
||||||
|
- **UI/Productization coverage decision**: update existing page reports only when visible behavior materially changes. No new route/archetype expected.
|
||||||
|
- **Coverage artifacts to update**: likely `ui-009-provider-connections.md` and `ui-077-required-permissions.md` if labels/counts/actions visibly change; dashboard/workspace/inbox page reports only if default-visible behavior materially changes.
|
||||||
|
- **No-impact rationale**: N/A.
|
||||||
|
- **Navigation / Filament provider-panel handling**: no panel provider or path change. Provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||||
|
- **Screenshot or page-report need**: focused screenshots for changed Provider Connections/Required Permissions states if browser smoke is added.
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes.
|
||||||
|
- **Systems touched**: provider connection summaries, permission diagnostics, provider capability evaluation, readiness guidance, dashboard/overview/inbox readiness, provider operation start links, badges.
|
||||||
|
- **Shared abstractions reused**: `BadgeCatalog`/`BadgeRenderer`, `RequiredPermissionsLinks`, `OperationRunLinks`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, `UiEnforcement`, `WorkspaceUiEnforcement`, provider capability registry/evaluator as input.
|
||||||
|
- **New abstraction introduced? why?**: one provider readiness resolver/result contract because current local truth can already diverge across real surfaces and create false readiness.
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: `ProviderReadinessResolutionAdapter` produces guidance but not canonical count rows and not all health/readiness consumers. `ProviderCapabilityEvaluator` evaluates capabilities but does not own all product readiness states/actions/counts. `ManagedEnvironmentRequiredPermissionsViewModelBuilder` owns the page matrix but not provider connection health.
|
||||||
|
- **Bounded deviation / spread control**: resolver owns provider readiness only. It must not become a generic proof framework, onboarding workflow, or multi-provider engine.
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes, existing provider verification start and proof links only.
|
||||||
|
- **Central contract reused**: existing OperationRun start UX path through `StartVerification`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, `OperationRunLinks`, and `OperationRunService`.
|
||||||
|
- **Delegated UX behaviors**: queued toast, dedupe, blocked/failure messaging, run link, terminal notification, and operation URL resolution remain on existing shared paths.
|
||||||
|
- **Surface-owned behavior kept local**: render resolver-recommended action if actor can perform it; otherwise render disabled/hidden state through existing UI enforcement.
|
||||||
|
- **Queued DB-notification policy**: unchanged.
|
||||||
|
- **Terminal notification path**: unchanged central lifecycle mechanism.
|
||||||
|
- **Exception path**: none.
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Provider-owned seams**: Microsoft Graph permission names, admin consent URL, provider-specific verification details, provider-owned error reason detail.
|
||||||
|
- **Platform-core seams**: readiness state, freshness state, required/effective permission coverage, row/count semantics, recommended action, scope matching.
|
||||||
|
- **Neutral platform terms / contracts preserved**: provider connection, verification, freshness, required permission, granted permission, effective permission, permission coverage, readiness, scope, next action.
|
||||||
|
- **Retained provider-specific semantics and why**: Microsoft permission names remain secondary technical detail because current Microsoft provider remediation still needs them.
|
||||||
|
- **Bounded extraction or follow-up path**: Provider Onboarding & Permissions Resolution Adapter can build on this resolver later.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
- Inventory-first / snapshots-second: no inventory truth change.
|
||||||
|
- Read/write separation: resolver/render paths are read-only. Verification action continues existing OperationRun start path.
|
||||||
|
- Graph contract path: no Graph calls during render. Any verification work remains through existing services/jobs and Graph client boundaries.
|
||||||
|
- Deterministic capabilities: resolver output is testable and deterministic.
|
||||||
|
- RBAC-UX: workspace/environment membership is 404 boundary; missing capability is 403 or disabled/hidden per existing UI pattern.
|
||||||
|
- Workspace isolation: resolver scopes by workspace and managed environment before returning readiness/action data.
|
||||||
|
- Tenant/managed-environment isolation: wrong-scope grants and provider connections cannot satisfy current permission coverage.
|
||||||
|
- OperationRun start UX: verify-provider action reuses central OperationRun UX. No local toast/link composition.
|
||||||
|
- Test governance: Unit, Feature/Filament, and Browser proof are explicitly scoped.
|
||||||
|
- Proportionality: new resolver is justified by current false provider readiness and multiple existing consumers.
|
||||||
|
- No premature abstraction: resolver replaces duplicated local truth and is not a multi-provider framework.
|
||||||
|
- Persisted truth: no new table expected by default; existing schema is used first.
|
||||||
|
- Behavioral state: new derived states change next action and readiness gating.
|
||||||
|
- UI semantics: no new badge framework; use existing badge/status helpers.
|
||||||
|
- Shared pattern first: existing Spec 353 guidance and provider capability systems are reused or delegated to the resolver.
|
||||||
|
- Provider boundary: platform-core readiness vocabulary stays provider-neutral.
|
||||||
|
- Filament-native UI: use existing Filament surfaces/components; no custom styling expansion.
|
||||||
|
- UI/Productization coverage: changed visible surfaces update existing UI audit artifacts or document explicit no-route/no-archetype impact.
|
||||||
|
|
||||||
|
## Domain And Data Implications
|
||||||
|
|
||||||
|
No migration is expected by default. Existing fields appear sufficient for v1:
|
||||||
|
|
||||||
|
- `provider_connections.workspace_id`
|
||||||
|
- `provider_connections.managed_environment_id`
|
||||||
|
- `provider_connections.provider`
|
||||||
|
- `provider_connections.is_enabled`
|
||||||
|
- `provider_connections.consent_status`
|
||||||
|
- `provider_connections.verification_status`
|
||||||
|
- `provider_connections.last_health_check_at`
|
||||||
|
- `provider_connections.last_error_reason_code`
|
||||||
|
- `provider_connections.last_error_message`
|
||||||
|
- `managed_environment_permissions.workspace_id`
|
||||||
|
- `managed_environment_permissions.managed_environment_id`
|
||||||
|
- `managed_environment_permissions.permission_key`
|
||||||
|
- `managed_environment_permissions.status`
|
||||||
|
- `managed_environment_permissions.details`
|
||||||
|
- `managed_environment_permissions.last_checked_at`
|
||||||
|
- verification `OperationRun.context.provider_connection_id`
|
||||||
|
- verification report payloads where current code already uses them
|
||||||
|
|
||||||
|
If implementation cannot distinguish current/fresh verification batch scope from stale stored grants using existing fields, stop and update spec/plan before adding schema. Any schema change must be minimal and clean-cut under the pre-production doctrine.
|
||||||
|
|
||||||
|
## Resolver Design Notes
|
||||||
|
|
||||||
|
Expected result fields:
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider_connection_id: int|null
|
||||||
|
scope_type: string
|
||||||
|
scope_id: int|null
|
||||||
|
readiness_state: string
|
||||||
|
connection_state: string
|
||||||
|
verification_state: string
|
||||||
|
verification_checked_at: string|null
|
||||||
|
verification_expires_at: string|null
|
||||||
|
is_verification_fresh: bool
|
||||||
|
required_count: int
|
||||||
|
granted_required_count: int
|
||||||
|
missing_required_count: int
|
||||||
|
blocked_required_count: int
|
||||||
|
expired_required_count: int
|
||||||
|
unknown_required_count: int
|
||||||
|
not_applicable_count: int
|
||||||
|
permission_rows: list<array>
|
||||||
|
primary_reason: string
|
||||||
|
blocking_reasons: list<string>
|
||||||
|
recommended_action: array|null
|
||||||
|
can_view_technical_detail: bool
|
||||||
|
can_manage_provider: bool
|
||||||
|
child_results: list<ProviderReadinessResult>|null
|
||||||
|
```
|
||||||
|
|
||||||
|
Entry-point shapes:
|
||||||
|
|
||||||
|
- Provider-connection, required-permission, and freshness entry points return a single provider-scoped result.
|
||||||
|
- Environment entry points return an aggregate result with provider-scoped child results for actor-authorized provider connections in the environment.
|
||||||
|
- Workspace entry points return an aggregate result with environment/provider-scoped child results for actor-authorized environments and provider connections.
|
||||||
|
- Aggregate results sum canonical count fields from children, use the same readiness-state precedence as provider-scoped results, and choose the recommended action from the highest-severity child blocker.
|
||||||
|
|
||||||
|
Permission rows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
permission_key: string
|
||||||
|
product_label: string
|
||||||
|
provider_permission_name: string|null
|
||||||
|
state: Granted|Missing|Blocked|Expired|Unknown|Not applicable
|
||||||
|
required_for: list<string>
|
||||||
|
is_required: bool
|
||||||
|
is_effective: bool
|
||||||
|
matched_grant_id: int|null
|
||||||
|
last_verified_at: string|null
|
||||||
|
reason: string
|
||||||
|
recommended_action: array|null
|
||||||
|
is_technical_only: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
State ordering:
|
||||||
|
|
||||||
|
1. Not configured if provider connection is absent/disconnected.
|
||||||
|
2. Failed if latest verification failed.
|
||||||
|
3. Expired if latest verification or permission evidence is stale.
|
||||||
|
4. Blocked if provider or admin consent blocks required permissions.
|
||||||
|
5. Needs attention if required permissions are missing.
|
||||||
|
6. Unknown if sufficient verification data is unavailable.
|
||||||
|
7. Ready only when connection is valid, verification is fresh, and all required permissions are effective.
|
||||||
|
|
||||||
|
Grant/row matching order:
|
||||||
|
|
||||||
|
1. Correct workspace.
|
||||||
|
2. Correct managed environment.
|
||||||
|
3. Correct provider connection and provider account/context as represented by the current `ProviderConnection` identity, provider key, managed-environment binding, and same-provider verification context.
|
||||||
|
4. Correct provider namespace/provider key.
|
||||||
|
5. Fresh verification basis.
|
||||||
|
6. Non-revoked/non-error grant.
|
||||||
|
7. Most recent verified timestamp.
|
||||||
|
8. Deterministic ID tie-breaker.
|
||||||
|
|
||||||
|
## Route And Authorization Plan
|
||||||
|
|
||||||
|
- Resolver must not be the only security boundary. Direct routes/actions remain policy/gate protected.
|
||||||
|
- Product-safe readiness summaries are returned only after workspace/environment entitlement.
|
||||||
|
- Provider-changing actions are returned as enabled only for actors with existing provider manage/run capability.
|
||||||
|
- Readonly actors may see product-safe summaries but get no provider-changing action.
|
||||||
|
- Technical detail links/actions are omitted unless actor has technical/manage capability.
|
||||||
|
- Wrong-scope provider connections and permission rows are ignored or denied-as-not-found by surrounding policies.
|
||||||
|
- If existing data cannot distinguish provider account/context for a permission that requires that distinction, implementation must stop and update spec/plan before adding schema. It must not assume the account matches.
|
||||||
|
|
||||||
|
## Filament And Livewire Plan
|
||||||
|
|
||||||
|
- Filament v5 / Livewire v4.0+ compliance is required; the app currently uses Livewire 4.1.4.
|
||||||
|
- Panel provider registration remains `apps/platform/bootstrap/providers.php`; no provider registration changes are expected.
|
||||||
|
- `ProviderConnectionResource` currently disables global search and must remain safe. Do not add global search. If implementation touches any globally searchable resource, verify it has View/Edit page and scoped query.
|
||||||
|
- No destructive action is introduced. Existing credential/provider-changing actions must keep `->action(...)`, confirmation where high-impact/destructive, server-side authorization, audit logging, and tests.
|
||||||
|
- No new Filament assets are expected. If implementation registers assets unexpectedly, deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Add or update focused resolver tests for:
|
||||||
|
|
||||||
|
- fresh verification + all required permissions -> `Ready`
|
||||||
|
- stale verification + old grants -> `Expired`, not ready
|
||||||
|
- verification failed -> `Failed`, not ready
|
||||||
|
- no verification -> `Unknown`, not ready
|
||||||
|
- connected provider without verification -> `Unknown` or `Needs attention`, not ready
|
||||||
|
- missing required permission -> `Needs attention`, not ready
|
||||||
|
- blocked/admin consent denied -> `Blocked`
|
||||||
|
- wrong workspace/environment/provider connection grant ignored
|
||||||
|
- stale grant -> `Expired`
|
||||||
|
- count invariant
|
||||||
|
- deterministic ordering
|
||||||
|
- actor without manage capability has no provider-changing action
|
||||||
|
- actor without technical permission has no raw technical detail
|
||||||
|
|
||||||
|
### Feature / Filament Tests
|
||||||
|
|
||||||
|
Add or update tests for:
|
||||||
|
|
||||||
|
- Required Permissions page canonical counts and labels
|
||||||
|
- no `Present 0` when grants are effective
|
||||||
|
- Provider Connection page does not show `Healthy` for stale verification
|
||||||
|
- Environment Dashboard consumes resolver output
|
||||||
|
- Workspace Overview consumes resolver output where repo-real
|
||||||
|
- Governance Inbox provider-related items consume resolver output where repo-real
|
||||||
|
- readonly users cannot run verify/reconnect/manage actions
|
||||||
|
- manage-capable users can access existing verify/reconnect where supported
|
||||||
|
- wrong-scope grants are not counted
|
||||||
|
- raw provider payload absent by default
|
||||||
|
|
||||||
|
### Browser Smoke
|
||||||
|
|
||||||
|
Add or update one focused browser smoke:
|
||||||
|
|
||||||
|
- stale provider is not `Healthy`/`Ready`
|
||||||
|
- fresh fully-permissioned provider is `Ready`
|
||||||
|
- counts match rows
|
||||||
|
- `Present 0` is absent
|
||||||
|
- missing permissions show actionable copy
|
||||||
|
- readonly user cannot trigger provider management actions
|
||||||
|
- manage-capable user can access verify/reconnect where supported
|
||||||
|
- no raw provider payload/default technical dump
|
||||||
|
- no 500/Livewire/Filament/console errors
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
Preferred targeted commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec394
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections tests/Feature/RequiredPermissions tests/Feature/Verification
|
||||||
|
cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec394ProviderFreshnessPermissionSmokeTest.php
|
||||||
|
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Use exact existing file names discovered during implementation. If browser suite failures are unrelated legacy/global smokes, document full command, pass/fail count, non-Spec394 failures, and why non-blocking.
|
||||||
|
|
||||||
|
## Rollout And Deployment Considerations
|
||||||
|
|
||||||
|
- **Env vars**: none expected.
|
||||||
|
- **Migrations**: none expected by default. If added, use safe clean-cut pre-production migration and update spec/plan first.
|
||||||
|
- **Queues**: no new queue family. Existing provider verification jobs/OperationRun behavior unchanged.
|
||||||
|
- **Scheduler/cron**: none expected.
|
||||||
|
- **Storage/volumes**: none expected.
|
||||||
|
- **Graph scopes**: none expected.
|
||||||
|
- **Filament assets**: none expected.
|
||||||
|
- **Staging/production**: staging must validate changed provider/readiness UI before production. Because there is no production deployment requirement for legacy data, remove incorrect semantics rather than preserving them.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1 - Inventory And Current Truth
|
||||||
|
|
||||||
|
Map every local provider health/readiness/freshness/permission count path and record its source, visible label, count source, freshness source, scope source, authorization rule, stale-as-healthy risk, and row/count mismatch risk.
|
||||||
|
|
||||||
|
### Phase 2 - Resolver Contract And Unit Proof
|
||||||
|
|
||||||
|
Create or consolidate the canonical resolver/result shape and prove canonical scenarios with unit tests before broad surface changes.
|
||||||
|
|
||||||
|
### Phase 3 - Required Permissions Count And Row Semantics
|
||||||
|
|
||||||
|
Replace `Present` and page-local count behavior with canonical count and row states. Keep raw provider names secondary.
|
||||||
|
|
||||||
|
### Phase 4 - Provider Connections And Guidance Consumers
|
||||||
|
|
||||||
|
Replace local readiness/health logic in `ProviderConnectionSurfaceSummary`, provider resource pages, and Spec 353 guidance adapter with resolver output or delegation.
|
||||||
|
|
||||||
|
### Phase 5 - Dashboard, Workspace, Inbox, And Downstream Consumers
|
||||||
|
|
||||||
|
Adopt resolver output in Environment Dashboard, Workspace Overview, Governance Inbox, and direct restore/evidence/review readiness consumers where repo-real. Document any remaining local path.
|
||||||
|
|
||||||
|
### Phase 6 - RBAC, Technical Detail, And Raw Payload Gating
|
||||||
|
|
||||||
|
Enforce action capability boundaries and ensure raw provider/permission detail is hidden or capability-gated.
|
||||||
|
|
||||||
|
### Phase 7 - Fixture, Label, And Legacy Cleanup
|
||||||
|
|
||||||
|
Update fixtures, tests, localization keys, old action/copy labels, and guard tests so misleading legacy behavior is removed, not aliased.
|
||||||
|
|
||||||
|
### Phase 8 - Browser Smoke And Human Sanity Check
|
||||||
|
|
||||||
|
Run focused browser smoke and prepare human product sanity checklist. Human reviewer must inspect the main affected screens before marking implementation complete.
|
||||||
|
|
||||||
|
### Phase 9 - Close-Out
|
||||||
|
|
||||||
|
Run validation commands, `pint --dirty`, `git diff --check`, and produce the required implementation report.
|
||||||
|
|
||||||
|
## Risks And Controls
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Control |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| Existing fixtures assume `Present` or old health states | High | Medium | Update fixtures to canonical model; no compatibility adapter |
|
||||||
|
| Resolver becomes too broad | Medium | Medium | Keep it provider readiness only; no onboarding/dashboard expansion |
|
||||||
|
| Schema cannot represent verification batch freshness | Medium | High | Stop, update spec/plan, add minimal clean schema only if required |
|
||||||
|
| Browser scope expands too far | Medium | Medium | One focused Spec394 smoke plus named existing affected tests only |
|
||||||
|
| Readonly technical detail behavior changes unexpectedly | Medium | Medium | Product-safe summary remains visible; raw details gated by manage/technical capability |
|
||||||
|
| Downstream readiness starts surfacing more blockers | High | Medium | Correct by design if provider is stale/missing; document direct consumers migrated |
|
||||||
|
|
||||||
|
## Filament v5 Output Contract For Implementation Close-Out
|
||||||
|
|
||||||
|
Later implementation response must explicitly state:
|
||||||
|
|
||||||
|
1. Livewire v4.0+ compliance.
|
||||||
|
2. Provider registration location: `apps/platform/bootstrap/providers.php`; no panel-provider change expected.
|
||||||
|
3. Global search posture: `ProviderConnectionResource` remains non-globally-searchable unless spec/plan are updated first.
|
||||||
|
4. Destructive/high-impact actions: no new destructive action expected; provider-changing actions retain existing action handlers, confirmation where required, authorization, and audit.
|
||||||
|
5. Asset strategy: no new assets expected; `filament:assets` only if implementation registers assets.
|
||||||
|
6. Testing plan and actual tests run.
|
||||||
|
7. Deployment impact: env, migrations, queues, scheduler, storage, and Graph scopes.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
- Stop if implementation needs a new table or verification batch model; update spec/plan first.
|
||||||
|
- Stop if implementation needs a distinct provider-account identifier beyond the current `ProviderConnection` identity and verification context; update spec/plan first.
|
||||||
|
- Stop if a page-local provider readiness path appears necessary for product UI; fold it into the resolver or document a bounded exception.
|
||||||
|
- Stop if old tests can only pass by preserving stale-as-healthy, `Present`, wrong-scope grant, or stale-grant behavior.
|
||||||
|
- Stop if scope expands into provider onboarding wizard, new dashboard/card, new evidence/proof links, customer-facing provider internals, OAuth redesign, or new provider integration.
|
||||||
|
- Do not rewrite, normalize, uncheck, or remove implementation history from completed Specs 281, 283, 294, 339, 353, or 381.
|
||||||
657
specs/394-provider-freshness-permission-semantics/spec.md
Normal file
657
specs/394-provider-freshness-permission-semantics/spec.md
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
# Feature Specification: Spec 394 - Provider Freshness & Permission Semantics v1
|
||||||
|
|
||||||
|
**Feature Branch**: `394-provider-freshness-permission-semantics`
|
||||||
|
**Created**: 2026-06-21
|
||||||
|
**Status**: Draft / Ready for implementation preparation review
|
||||||
|
**Type**: Bugfix / correctness / trust boundary / productization
|
||||||
|
**Priority**: P1
|
||||||
|
**Runtime posture**: Clean canonical replacement over existing provider freshness, provider readiness, verification, and required-permission count behavior. No legacy compatibility shim, no parallel provider health truth, no new provider integration, and no UI expansion.
|
||||||
|
**Input**: User-provided full Spec 394 draft plus repo truth from provider connections, required permissions, verification, provider capability, environment dashboard, workspace overview, governance inbox, restore/review readiness, and related completed specs.
|
||||||
|
|
||||||
|
## Dependencies And Historical Context
|
||||||
|
|
||||||
|
Spec 394 is a correctness follow-up over existing provider scope, capability, verification, guidance, and readiness work:
|
||||||
|
|
||||||
|
- Spec 281 - Provider Connection Scope, completed/historical provider target-scope foundation.
|
||||||
|
- Spec 283 - Provider Capability Registry, implemented provider capability vocabulary and evaluator context.
|
||||||
|
- Spec 294 - Provider Verification Runtime Semantics Stabilization, completed provider/verification runtime context.
|
||||||
|
- Spec 339 - Provider Connection Scope Hardening, completed provider authority-source guardrails.
|
||||||
|
- Spec 353 - Provider Connections Resolution Guidance v1, implemented guidance hierarchy over existing provider truth.
|
||||||
|
- Spec 381 - Provider Resource Identity and Binding Foundation v1, completed provider-resource binding context, not modified by this spec.
|
||||||
|
- Spec 385 - Evidence and review readiness integration, completed downstream readiness context.
|
||||||
|
- Spec 390 - Restore readiness resolution adapter, prepared/runtime context for restore readiness consumers.
|
||||||
|
- Spec 393 - Evidence anchor reconciliation, adjacent trust-boundary cleanup pattern.
|
||||||
|
|
||||||
|
Repo-truth observations that shape this spec:
|
||||||
|
|
||||||
|
- `ProviderConnection` stores `verification_status`, `last_health_check_at`, `consent_status`, `is_enabled`, scope fields, and provider identity metadata.
|
||||||
|
- `ManagedEnvironmentPermission` stores permission rows with `managed_environment_id`, `workspace_id`, `permission_key`, `status`, `details`, and `last_checked_at`.
|
||||||
|
- `ManagedEnvironmentPermissionService` compares configured required permissions against stored or live evidence but currently keys granted rows by permission key for one environment and does not expose a canonical provider-connection or verification-batch result.
|
||||||
|
- `ManagedEnvironmentRequiredPermissionsViewModelBuilder` currently derives `present`, `missing_application`, `missing_delegated`, `error`, and freshness from permission row timestamps. Its filters still include the legacy `present` label.
|
||||||
|
- `ProviderConnectionSurfaceSummary` maps `ProviderVerificationStatus::Healthy` directly to `Ready`, independent from permission freshness and required permission coverage.
|
||||||
|
- `ProviderCapabilityEvaluator` blocks missing/error/stale permission evidence but is not the one canonical readiness and count contract for all product surfaces.
|
||||||
|
- `ProviderReadinessResolutionAdapter` already detects stale health checks and stale permission snapshots for guidance cards, but this behavior is not the single source used by all provider health, table, badge, count, and downstream readiness consumers.
|
||||||
|
- Provider UI guidance from Spec 353 is intentionally a bounded guidance layer over existing truth. Spec 394 must replace inconsistent truth calculations, not add a second visible guidance layer.
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Provider readiness is a trust boundary, but current provider surfaces can derive health, freshness, required permission counts, and readiness from different local sources.
|
||||||
|
- **Today's failure**: Required Permissions can show ambiguous counts such as `Present 0` while granted permission rows exist, and Provider Connections can show `Healthy` or `Ready` from `verification_status=healthy` even when verification evidence or permission evidence is stale.
|
||||||
|
- **User-visible improvement**: Operators see one truthful provider state across Provider Connections, Required Permissions, Environment Dashboard, Workspace Overview, Governance Inbox, and downstream readiness surfaces. Stale, failed, unknown, missing, wrong-scope, or blocked provider evidence never appears as `Healthy` or `Ready`.
|
||||||
|
- **Smallest enterprise-capable version**: Consolidate provider readiness, verification freshness, permission coverage, row state, count semantics, recommended action, and capability-aware action visibility into one canonical resolver contract. Replace local visible health/readiness/count paths on affected existing surfaces. Add focused tests and one bounded browser smoke.
|
||||||
|
- **Explicit non-goals**: No new provider integration, no OAuth redesign, no new onboarding wizard, no new dashboard/card/page, no new raw evidence panel, no customer-facing provider internals, no broad Product Surface Contract Enforcement pass, no restore/review adapter rebuild beyond preventing direct provider surfaces from lying.
|
||||||
|
- **Permanent complexity imported**: One canonical resolver/result contract, a derived provider-readiness state family, explicit required-permission row/count vocabulary, focused Unit/Feature/Filament/Browser tests, and artifact/reporting obligations. No new table is expected by default.
|
||||||
|
- **Why now**: Provider readiness now feeds evidence capture, review readiness, restore readiness, governance checks, and customer-safe outputs. False readiness is a direct trust failure, not a cosmetic label issue.
|
||||||
|
- **Why not local**: `ProviderConnectionSurfaceSummary`, `ManagedEnvironmentRequiredPermissionsViewModelBuilder`, `ProviderCapabilityEvaluator`, `ProviderReadinessResolutionAdapter`, dashboard builders, and tests already encode overlapping provider truth. Page-local fixes would preserve drift and allow stale verification to become `Healthy` elsewhere.
|
||||||
|
- **Approval class**: Core Enterprise.
|
||||||
|
- **Red flags triggered**: New resolver abstraction, provider state vocabulary, multiple surfaces, and canonical semantics. Defense: the resolver replaces existing parallel truth for a security/trust boundary, has multiple real consumers now, remains derived unless existing schema cannot represent the contract, and explicitly forbids UI expansion and compatibility shims.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve.
|
||||||
|
|
||||||
|
## Candidate Source And Completed-Spec Guardrail
|
||||||
|
|
||||||
|
- **Selected candidate**: Provider Freshness & Permission Semantics v1.
|
||||||
|
- **Source location**: Direct user-provided Spec 394 draft in this conversation. Roadmap relationship is the manual-promotion lane `Provider readiness and onboarding productization` in `docs/product/roadmap.md` and `docs/product/spec-candidates.md`.
|
||||||
|
- **Why selected**: The active auto-prep queue is empty, but the user explicitly provided a numbered manual candidate. Repo inspection confirms visible drift in provider readiness/count/freshness seams.
|
||||||
|
- **Close alternatives deferred**:
|
||||||
|
- Provider onboarding redesign: deferred because this spec must fix truth before adding setup flows.
|
||||||
|
- Secondary-action density cleanup from Spec 353: deferred because this spec is correctness, not action-density polish.
|
||||||
|
- Cross-domain indicator runtime follow-through: deferred because this spec is provider-specific readiness truth.
|
||||||
|
- Governance artifact lifecycle/retention runtime: unrelated trust lane.
|
||||||
|
- Product Surface Contract Enforcement pass: broader later pass, not needed to fix provider readiness truth.
|
||||||
|
- **Completed-spec check**:
|
||||||
|
- No `specs/394-*` package existed before this preparation.
|
||||||
|
- Specs 281, 283, 294, 339, 353, and 381 contain completed-task, implementation, review, close-out, or smoke-history signals and are read-only historical context.
|
||||||
|
- Spec 353 guidance surfaces and UI audit artifacts must not be rewritten into preparation state. Spec 394 may require follow-up updates only if implementation materially changes visible UI behavior.
|
||||||
|
- **Smallest viable implementation slice**: Existing provider readiness/health/count consumers only: Provider Connections, Required Permissions, Environment Dashboard provider area, Workspace Overview provider signals, Governance Inbox provider-related items if present, and direct provider-blocking summaries used by restore/evidence/review readiness.
|
||||||
|
- **Gate result**: PASS. The candidate is user-provided, unprepared, not completed, roadmap-aligned, and narrowed to a bounded trust-boundary correction.
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace-owned provider connection plus managed-environment permission/readiness scope.
|
||||||
|
- **Primary Routes / Surfaces**:
|
||||||
|
- `/admin/provider-connections`
|
||||||
|
- `/admin/provider-connections/{record}`
|
||||||
|
- `/admin/provider-connections/{record}/edit`
|
||||||
|
- `/admin/workspaces/{workspace}/environments/{environment}/required-permissions`
|
||||||
|
- `/admin/workspaces/{workspace}/environments/{environment}`
|
||||||
|
- `/admin` Workspace Overview provider/readiness signals where repo-real
|
||||||
|
- Governance Inbox provider-related rows where repo-real
|
||||||
|
- Restore, evidence, review, and report readiness summaries only where they directly consume provider health/readiness state
|
||||||
|
- **Data Ownership**:
|
||||||
|
- `ProviderConnection` remains workspace-owned and managed-environment-linked.
|
||||||
|
- `ManagedEnvironmentPermission` remains managed-environment-owned permission evidence with workspace scope.
|
||||||
|
- Provider verification remains represented by existing provider connection fields, `OperationRun` verification reports, and permission row timestamps unless implementation proves a minimal clean schema change is necessary.
|
||||||
|
- Provider account/context for v1 is the current `ProviderConnection` identity: provider connection ID, workspace ID, managed environment ID, provider key, persisted provider identity metadata already stored on the connection, and verification `OperationRun` context that references the same provider connection. A grant without an exact current provider connection, managed environment, workspace, and provider-key match cannot satisfy a required permission.
|
||||||
|
- No new persisted truth is expected by default. Any schema change must be clean, pre-production, reversible where practical, and justified in updated spec/plan before implementation.
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership and managed-environment entitlement are required before any provider readiness result or route is exposed.
|
||||||
|
- Product-safe readiness summaries require view capability.
|
||||||
|
- Provider-changing actions such as verify, reconnect, refresh grants, repair permissions, or admin consent require existing provider management/run capabilities.
|
||||||
|
- Technical detail requires an existing technical capability if present; otherwise use the stricter provider management capability.
|
||||||
|
- Non-member or wrong workspace/environment remains deny-as-not-found. Member without capability remains 403 or disabled/hidden per existing UI enforcement.
|
||||||
|
|
||||||
|
For canonical-view or mixed-scope specs:
|
||||||
|
|
||||||
|
- **Default filter behavior when environment-context is active**: Provider readiness must be resolved for the explicit workspace/environment/provider connection context. Workspace-wide summaries may aggregate only actor-authorized environments and must not pick wrong-scope grants.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Resolver methods must scope by workspace, managed environment, provider connection, provider key, and actor entitlement before resolving grants, rows, links, or actions. Wrong-scope grants must not satisfy current required permissions.
|
||||||
|
- **Provider account/context stop condition**: If implementation discovers a required permission cannot be safely reconciled from the current `ProviderConnection` identity, managed-environment scope, provider key, and verification context, stop and update spec/plan before adding a separate provider-account identifier or schema.
|
||||||
|
|
||||||
|
## Canonical Provider Readiness Contract
|
||||||
|
|
||||||
|
The implementation must create or consolidate one resolver/service with behavior equivalent to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProviderReadinessResolver::forWorkspace($workspace, ?User $actor)
|
||||||
|
ProviderReadinessResolver::forEnvironment($environment, ?User $actor)
|
||||||
|
ProviderReadinessResolver::forProviderConnection($providerConnection, ?User $actor)
|
||||||
|
ProviderReadinessResolver::requiredPermissions($providerConnection, $scope, ?User $actor)
|
||||||
|
ProviderReadinessResolver::freshness($providerConnection, ?User $actor)
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact class and method names may differ if repo ownership points to a better namespace. The product-facing decision must still be one canonical contract consumed by affected surfaces.
|
||||||
|
|
||||||
|
Resolver result fields:
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider_connection_id
|
||||||
|
scope_type
|
||||||
|
scope_id
|
||||||
|
readiness_state
|
||||||
|
connection_state
|
||||||
|
verification_state
|
||||||
|
verification_checked_at nullable
|
||||||
|
verification_expires_at nullable
|
||||||
|
is_verification_fresh
|
||||||
|
required_count
|
||||||
|
granted_required_count
|
||||||
|
missing_required_count
|
||||||
|
blocked_required_count
|
||||||
|
expired_required_count
|
||||||
|
unknown_required_count
|
||||||
|
not_applicable_count
|
||||||
|
permission_rows[]
|
||||||
|
primary_reason
|
||||||
|
blocking_reasons[]
|
||||||
|
recommended_action
|
||||||
|
can_view_technical_detail
|
||||||
|
can_manage_provider
|
||||||
|
child_results[] nullable for workspace/environment aggregate entry points
|
||||||
|
```
|
||||||
|
|
||||||
|
Entry-point result shape:
|
||||||
|
|
||||||
|
- `forProviderConnection(...)`, `requiredPermissions(...)`, and `freshness(...)` return one provider-scoped resolver result.
|
||||||
|
- `forEnvironment(...)` returns one aggregate result with `scope_type=environment`, `provider_connection_id=null` when multiple provider connections are represented, and `child_results[]` containing provider-scoped resolver results for actor-authorized provider connections in that environment.
|
||||||
|
- `forWorkspace(...)` returns one aggregate result with `scope_type=workspace`, `provider_connection_id=null`, and `child_results[]` containing environment/provider-scoped results for actor-authorized environments and provider connections in that workspace.
|
||||||
|
- Aggregate readiness uses the same state precedence as provider-scoped readiness, sums canonical count fields from child results, and takes the recommended action from the highest-severity child blocker. Product surfaces must not hand-roll workspace or environment aggregation from raw permission rows.
|
||||||
|
|
||||||
|
Each permission row fields:
|
||||||
|
|
||||||
|
```text
|
||||||
|
permission_key
|
||||||
|
product_label
|
||||||
|
provider_permission_name nullable
|
||||||
|
state
|
||||||
|
required_for
|
||||||
|
is_required
|
||||||
|
is_effective
|
||||||
|
matched_grant_id nullable
|
||||||
|
last_verified_at nullable
|
||||||
|
reason
|
||||||
|
recommended_action nullable
|
||||||
|
is_technical_only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Canonical Product States
|
||||||
|
|
||||||
|
Provider-facing product states:
|
||||||
|
|
||||||
|
- `Ready`
|
||||||
|
- `Needs attention`
|
||||||
|
- `Blocked`
|
||||||
|
- `Not configured`
|
||||||
|
- `Expired`
|
||||||
|
- `Failed`
|
||||||
|
- `Unknown`
|
||||||
|
|
||||||
|
Provider readiness mapping:
|
||||||
|
|
||||||
|
- Provider connected + verification fresh + all required permissions effective -> `Ready`
|
||||||
|
- Provider connected + permissions partially missing -> `Needs attention`
|
||||||
|
- Provider connected + required permissions blocked or admin consent denied -> `Blocked`
|
||||||
|
- Provider missing or disconnected -> `Not configured`
|
||||||
|
- Verification stale -> `Expired`
|
||||||
|
- Verification failed -> `Failed`
|
||||||
|
- Insufficient data -> `Unknown`
|
||||||
|
|
||||||
|
Individual required permission row states:
|
||||||
|
|
||||||
|
- `Granted`
|
||||||
|
- `Missing`
|
||||||
|
- `Blocked`
|
||||||
|
- `Expired`
|
||||||
|
- `Unknown`
|
||||||
|
- `Not applicable`
|
||||||
|
|
||||||
|
Row mapping:
|
||||||
|
|
||||||
|
- Fresh matching grant exists -> `Granted`
|
||||||
|
- No matching grant -> `Missing`
|
||||||
|
- Provider denies or admin consent blocked -> `Blocked`
|
||||||
|
- Matching grant exists but verification is stale -> `Expired`
|
||||||
|
- Insufficient verification data -> `Unknown`
|
||||||
|
- Permission not required in this scope -> `Not applicable`
|
||||||
|
|
||||||
|
Required permission counts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
required_count
|
||||||
|
granted_required_count
|
||||||
|
missing_required_count
|
||||||
|
blocked_required_count
|
||||||
|
expired_required_count
|
||||||
|
unknown_required_count
|
||||||
|
not_applicable_count
|
||||||
|
```
|
||||||
|
|
||||||
|
Invariant:
|
||||||
|
|
||||||
|
```text
|
||||||
|
required_count =
|
||||||
|
granted_required_count
|
||||||
|
+ missing_required_count
|
||||||
|
+ blocked_required_count
|
||||||
|
+ expired_required_count
|
||||||
|
+ unknown_required_count
|
||||||
|
```
|
||||||
|
|
||||||
|
`not_applicable_count` is outside `required_count` unless a later spec explicitly changes that product contract.
|
||||||
|
|
||||||
|
Forbidden ambiguous product-facing count labels:
|
||||||
|
|
||||||
|
- `Present`
|
||||||
|
- `Present 0`
|
||||||
|
- `OK count`
|
||||||
|
- `Permission count`
|
||||||
|
- `Granted rows`
|
||||||
|
- `Raw grants`
|
||||||
|
|
||||||
|
These terms may remain only in internal technical notes when clearly defined and not shown as default product UI.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
- **FR-394-001**: Any visible provider health/readiness state must be derived from the canonical resolver. Legacy helpers must delegate to it or be removed.
|
||||||
|
- **FR-394-002**: Stale provider verification must never render as `Healthy`, `Ready`, `Current`, or `OK`.
|
||||||
|
- **FR-394-003**: Connected/configured provider state must not be treated as provider readiness.
|
||||||
|
- **FR-394-004**: Required permission summary counts and row states must come from the same resolver result.
|
||||||
|
- **FR-394-005**: A grant is effective only when it matches the current workspace, managed environment, provider connection, provider account/context represented by the current provider connection identity, provider namespace, required permission, and fresh verification basis.
|
||||||
|
- **FR-394-006**: Permission evidence from stale verification must not satisfy current required permissions. It may appear only as technical/historical detail when authorized.
|
||||||
|
- **FR-394-007**: Unknown permission state fails closed as `Unknown`, `Needs attention`, or a non-ready equivalent.
|
||||||
|
- **FR-394-008**: Missing permissions must produce actionable product copy and a truthful next action such as review required permissions, verify provider, reconnect provider, request admin consent, or open provider connection.
|
||||||
|
- **FR-394-009**: Provider readiness consumers in provider connections, required permissions, environment/workspace readiness, governance inbox, and direct downstream readiness summaries must use the canonical resolver or be documented as an explicit remaining local path with reason and follow-up.
|
||||||
|
- **FR-394-010**: Default product UI must not expose raw provider payloads, raw permission JSON, opaque provider IDs, grant object IDs, token details, verification payloads, Graph/API dumps, or internal job/run IDs.
|
||||||
|
- **FR-394-011**: Default permission labels must explain product purpose first. Raw provider permission names may appear only as secondary technical detail where authorized.
|
||||||
|
- **FR-394-012**: Resolver output must be deterministic. Multiple matching grants must use explicit ordering: correct scope, fresh verification batch, non-revoked grant, most recent verification timestamp, deterministic ID tie-breaker.
|
||||||
|
- **FR-394-013**: Provider-changing actions must require existing management/run capability. View-only users may view product-safe summaries when authorized but cannot execute provider-changing actions.
|
||||||
|
- **FR-394-014**: Downstream readiness surfaces that depend on provider access must not override provider blockers from stale, failed, unknown, or missing provider state.
|
||||||
|
- **FR-394-015**: Old tests, fixtures, labels, translation keys, query ordering, action keys, and fallback logic preserving misleading provider health or `Present 0` behavior must be removed or updated.
|
||||||
|
|
||||||
|
## UX Requirements
|
||||||
|
|
||||||
|
Provider Connection summary should show:
|
||||||
|
|
||||||
|
- Provider
|
||||||
|
- Connection state
|
||||||
|
- Verification state
|
||||||
|
- Last verified at
|
||||||
|
- Required permissions state
|
||||||
|
- At most one primary action
|
||||||
|
|
||||||
|
Required Permissions should show canonical counts such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Required permissions: 8
|
||||||
|
Granted: 6
|
||||||
|
Missing: 2
|
||||||
|
Expired: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Rows should show:
|
||||||
|
|
||||||
|
- Permission purpose
|
||||||
|
- State
|
||||||
|
- Required for
|
||||||
|
- Last verified
|
||||||
|
- Next action
|
||||||
|
|
||||||
|
Stale provider copy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Provider verification expired.
|
||||||
|
Verify this provider to refresh permission status.
|
||||||
|
Required permissions cannot be trusted until verification is current.
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing permission copy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Required permission missing.
|
||||||
|
Admin consent is required.
|
||||||
|
Provider access needs attention.
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed primary actions by state:
|
||||||
|
|
||||||
|
- `Ready` -> View provider
|
||||||
|
- `Needs attention` -> Review required permissions
|
||||||
|
- `Expired` -> Verify provider
|
||||||
|
- `Failed` -> Review provider error
|
||||||
|
- `Not configured` -> Connect provider
|
||||||
|
- `Blocked` -> Resolve provider blocker
|
||||||
|
- `Unknown` -> Check provider status
|
||||||
|
|
||||||
|
This spec must reduce or preserve visible complexity. Wrong labels/states are replaced, not duplicated.
|
||||||
|
|
||||||
|
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||||
|
|
||||||
|
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||||
|
|
||||||
|
- [ ] No UI surface impact
|
||||||
|
- [x] Existing page changed
|
||||||
|
- [ ] New page/route added
|
||||||
|
- [ ] Navigation changed
|
||||||
|
- [ ] Filament panel/provider surface changed
|
||||||
|
- [ ] New modal/drawer/wizard/action added
|
||||||
|
- [x] New table/form/state added
|
||||||
|
- [ ] Customer-facing surface changed
|
||||||
|
- [ ] Dangerous action changed
|
||||||
|
- [x] Status/evidence/review presentation changed
|
||||||
|
- [x] Workspace/environment context presentation changed
|
||||||
|
|
||||||
|
## UI/Productization Coverage
|
||||||
|
|
||||||
|
- **Route/page/surface**:
|
||||||
|
- Provider Connections list/view/edit
|
||||||
|
- Environment Required Permissions
|
||||||
|
- Environment Dashboard provider area
|
||||||
|
- Workspace Overview provider/readiness signals where repo-real
|
||||||
|
- Governance Inbox provider-related items where repo-real
|
||||||
|
- Direct restore/evidence/review readiness summaries where provider state is consumed
|
||||||
|
- **Current or new page archetype**:
|
||||||
|
- Provider Connections: existing Provider / Integration Strategic Surface from `ui-009-provider-connections.md`
|
||||||
|
- Required Permissions: existing Provider / Integration Domain Pattern Surface from `ui-077-required-permissions.md`
|
||||||
|
- Environment Dashboard and Workspace Overview: existing operator overview surfaces
|
||||||
|
- Governance Inbox: existing operator decision queue
|
||||||
|
- **Design depth**: Domain Pattern Surface / Strategic Surface follow-through over existing surfaces.
|
||||||
|
- **Repo-truth level**: repo-verified.
|
||||||
|
- **Existing pattern reused**: Spec 353 provider readiness guidance card, existing Filament resource/page surfaces, existing `BadgeCatalog`/`BadgeRenderer`, existing `UiEnforcement`/`WorkspaceUiEnforcement`, existing OperationRun start UX for verification.
|
||||||
|
- **New pattern required**: one canonical provider readiness resolver/result contract; no new dashboard, no new onboarding wizard, no new raw technical table.
|
||||||
|
- **Screenshot required**: yes for focused browser smoke only if visible labels/counts/actions change, likely under `specs/394-provider-freshness-permission-semantics/artifacts/screenshots/`.
|
||||||
|
- **Page audit required**: update existing page reports only if visible page behavior materially changes:
|
||||||
|
- `docs/ui-ux-enterprise-audit/page-reports/ui-009-provider-connections.md`
|
||||||
|
- `docs/ui-ux-enterprise-audit/page-reports/ui-077-required-permissions.md`
|
||||||
|
- dashboard/workspace/governance page reports only if implementation materially changes default-visible provider/readiness behavior there
|
||||||
|
- **Customer-safe review required**: no direct customer-facing provider detail is added; downstream customer-safe readiness must not expose raw provider data.
|
||||||
|
- **Dangerous-action review required**: yes for provider-changing actions already present. They must remain capability-gated and confirmation/audit-safe where high-impact.
|
||||||
|
- **Coverage files updated or explicitly not needed**:
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||||
|
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
|
||||||
|
- [ ] `N/A - no reachable UI surface impact`
|
||||||
|
- **No-impact rationale when applicable**: N/A. Existing surfaces are materially changed because labels, states, counts, and primary actions may change.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes.
|
||||||
|
- **Interaction class(es)**: status messaging, action links, dashboard signals, readiness summaries, provider guidance, permission rows/counts, support/technical disclosure, OperationRun verification links.
|
||||||
|
- **Systems touched**:
|
||||||
|
- `App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary`
|
||||||
|
- `App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter`
|
||||||
|
- `App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder`
|
||||||
|
- `App\Services\Intune\ManagedEnvironmentPermissionService`
|
||||||
|
- `App\Support\Providers\Capabilities\ProviderCapabilityEvaluator`
|
||||||
|
- `App\Filament\Resources\ProviderConnectionResource`
|
||||||
|
- `App\Filament\Pages\EnvironmentRequiredPermissions`
|
||||||
|
- `App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder`
|
||||||
|
- `App\Support\Workspaces\WorkspaceOverviewBuilder`
|
||||||
|
- `App\Support\GovernanceInbox\GovernanceInboxSectionBuilder`
|
||||||
|
- existing restore/evidence/review readiness adapters where provider state is consumed
|
||||||
|
- **Existing pattern(s) to extend**: Spec 353 readiness guidance hierarchy, existing capability evaluator, existing provider connection scope hardening rules, existing OperationRun verification start path, existing badge catalog.
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: Existing `BadgeCatalog`/`BadgeRenderer`, `ProviderOperationStartResultPresenter`, `OperationRunLinks`, `RequiredPermissionsLinks`, `UiEnforcement`/`WorkspaceUiEnforcement`, and provider capability registry/evaluator where they remain the correct input.
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: Existing paths each solve part of the problem. None currently owns the complete product truth for readiness, freshness, permission row states, count invariants, scope matching, and action capability together.
|
||||||
|
- **Allowed deviation and why**: one canonical resolver is allowed because current local truth is already duplicated across multiple real product surfaces and can produce false `Healthy`/`Ready`.
|
||||||
|
- **Consistency impact**: Provider Connections, Required Permissions, dashboard guidance, workspace summaries, governance inbox, and direct downstream readiness summaries must share state names, freshness behavior, count semantics, next-action labels, and raw-detail gating.
|
||||||
|
- **Review focus**: no stale verification as healthy/ready, no `Present 0`, no wrong-scope grant satisfaction, no raw provider payloads, no compatibility shim, no second readiness truth.
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes, existing provider verification start/proof links only.
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: existing `StartVerification`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, `OperationRunLinks`, and `OperationRunService`.
|
||||||
|
- **Delegated start/completion UX behaviors**: verify-provider action must keep existing queued/deduped/blocked/run-link behavior. This spec may change when the action is primary or visible, not how OperationRun UX is composed.
|
||||||
|
- **Local surface-owned behavior that remains**: resolver output decides recommended action and capability visibility; surfaces render it through existing action patterns.
|
||||||
|
- **Queued DB-notification policy**: unchanged. No new queued notification family.
|
||||||
|
- **Terminal notification path**: unchanged central lifecycle mechanism.
|
||||||
|
- **Exception required?**: none.
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Boundary classification**: mixed. Provider-owned evidence and permission names feed a platform-core readiness contract.
|
||||||
|
- **Seams affected**: provider connection state, verification freshness, required permission coverage, permission evidence, provider capability evaluation, provider health labels, dashboard readiness signals, workspace/governance summaries, and downstream readiness blockers.
|
||||||
|
- **Neutral platform terms preserved or introduced**: provider connection, verification, freshness, required permission, granted permission, effective permission, permission coverage, readiness, scope, recommended action.
|
||||||
|
- **Provider-specific semantics retained and why**: Microsoft Graph permission names and admin-consent details remain necessary as secondary technical/operator detail for the current Microsoft provider.
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: product-facing labels use provider-neutral and purpose-first language. Raw Microsoft permission names stay secondary and do not define the platform-core readiness vocabulary.
|
||||||
|
- **Follow-up path**: deeper provider onboarding and permission resolution adapter remains a later follow-up; this spec fixes current truth only.
|
||||||
|
|
||||||
|
## 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 list/view/edit | yes | Native Filament resource plus shared provider summary/guidance | provider readiness, health, actions, verification proof | table, detail, header/action state | no | Existing routes only |
|
||||||
|
| Environment Required Permissions | yes | Native Filament page plus Blade summary/matrix | permission rows/counts, freshness, provider action | page summary, filters, rows, copy | no | Existing route only |
|
||||||
|
| Environment Dashboard provider area | yes | Existing dashboard builder/view | readiness signal and next action | dashboard decision/guidance | no | Existing dashboard only |
|
||||||
|
| Workspace Overview provider/readiness signal | conditional yes | Existing builder/view | workspace readiness aggregation | overview signal | no | Only if repo-real provider signals are consumed |
|
||||||
|
| Governance Inbox provider-related items | conditional yes | Existing inbox builder/view | provider blocker item truth | queue item/status/action | no | Only if repo-real provider items are consumed |
|
||||||
|
| Restore/evidence/review readiness summaries | conditional yes | Existing resources/builders | provider blocker propagation | readiness state | no | Direct provider-state consumers only |
|
||||||
|
|
||||||
|
## 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 view | Primary Decision Surface | Decide whether this provider can be trusted now | readiness state, verification freshness, permission coverage, one action | capability rows, verification history, technical provider detail | Primary because connection readiness is a trust authority | provider administration | removes parallel health/count interpretation |
|
||||||
|
| Provider Connections list | Secondary Context Surface | Find which connection needs attention | readiness state and scope-safe summary | detail page | Secondary because detail owns the decision | workspace integration scanning | avoids multiple top-level truth labels |
|
||||||
|
| Environment Required Permissions | Primary Decision Surface | Decide which permission/freshness blocker must be resolved | canonical counts, row states, freshness, one action | raw provider permission names and technical details | Primary for permission remediation | permission remediation workflow | replaces `Present` ambiguity |
|
||||||
|
| Environment Dashboard provider area | Secondary Context Surface | Confirm provider blocker and jump to resolver-owned remediation | canonical blocker and next action | provider/permission detail page | Secondary because dashboard summarizes | environment operations | prevents dashboard from claiming ready from old evidence |
|
||||||
|
| Workspace Overview / Governance Inbox | Secondary Context Surface | Prioritize affected environments/items | canonical blocker state and link | environment/provider details | Secondary aggregation | portfolio triage | avoids contradictory readiness signals |
|
||||||
|
|
||||||
|
## 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 | operator-MSP, workspace owner, support when authorized | readiness, verification freshness, required permission state, next action | last verification, capability groups, permission coverage | raw provider IDs/payloads excluded by default | View provider, verify provider, review permissions, connect provider | technical details capability-gated | canonical resolver result owns state once |
|
||||||
|
| Required Permissions | operator-MSP, workspace owner, support when authorized | required/granted/missing/blocked/expired/unknown counts and rows | provider permission names as secondary detail | raw permission JSON excluded by default | verify provider or review consent | raw details collapsed/gated | summary count aggregates rows |
|
||||||
|
| Dashboard/Workspace/Inbox | operator-MSP, readonly where authorized | blocker state and target link | none or concise proof | raw provider detail never default | open provider/permissions/remediation | technical detail hidden | aggregations consume resolver output |
|
||||||
|
|
||||||
|
## 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 list | List / Integration | CRUD / List-first Resource | Open provider or verify/review blocker | row opens detail | required | More/header | unchanged existing grouping | `/admin/provider-connections` | `/admin/provider-connections/{record}` | workspace, environment, provider | Provider connection | readiness and freshness | none |
|
||||||
|
| Provider Connections view/edit | Detail / Configuration Authority | Provider readiness detail/config | verify provider, review permissions, reconnect, or view provider | dedicated detail/edit | N/A | header More / details | existing confirmations retained | `/admin/provider-connections` | existing view/edit routes | workspace, environment, provider | Provider connection | canonical readiness | none |
|
||||||
|
| Required Permissions | List / Guidance / Diagnostic | read-first remediation page | verify provider or review/admin consent | same page matrix | forbidden | page body secondary links | none added | required permissions route | same page | workspace and environment | Required permissions | canonical counts and row states | existing inline diagnostic page |
|
||||||
|
| Environment Dashboard | Overview / Decision | operator environment dashboard | open provider remediation | cards/links | N/A | dashboard guidance | unchanged | environment route | same | workspace/environment/provider | Provider readiness | provider blocker cannot be overridden | none |
|
||||||
|
| Workspace Overview / Governance Inbox | Queue / Overview | aggregation/triage | open environment/provider remediation | item/action link | N/A | item secondary links | unchanged | existing routes | existing target routes | workspace and environment | Provider readiness | blocker state | conditional only |
|
||||||
|
|
||||||
|
## 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 | Workspace operator | Decide whether provider is ready now | integration list/detail | Can TenantPilot rely on this provider right now? | connection state, verification freshness, permission coverage, recommended action | raw provider permission names, verification history | connection, verification, permission coverage, readiness | TenantPilot provider metadata or provider verification operation | Verify provider, Review required permissions, Connect provider, View provider | existing credential/disable/reconnect actions only |
|
||||||
|
| Required Permissions | Workspace operator | Decide which permission or freshness issue blocks provider use | diagnostic remediation page | Are required permissions effective for this scope? | canonical counts, row states, last verified, next action | provider permission names, technical detail | permission row state, freshness, readiness | verification operation only | Verify provider, Request admin consent, Open connection | none added |
|
||||||
|
| Dashboard/Workspace/Inbox | Governance operator | Prioritize provider blockers across environments | overview/queue | Which provider blocker needs attention? | canonical state, impact, link | deeper provider detail | readiness only | none | Open provider/permissions | none added |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no persisted source of truth by default. One derived canonical resolver becomes the product-facing decision contract.
|
||||||
|
- **New persisted entity/table/artifact?**: no by default. Existing `ProviderConnection`, `ManagedEnvironmentPermission`, `OperationRun`, and verification reports should be used first.
|
||||||
|
- **New abstraction?**: yes, a canonical provider readiness resolver/result contract.
|
||||||
|
- **New enum/state/reason family?**: yes, derived product-facing readiness and permission row states. They must change behavior and next action, not just presentation.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no.
|
||||||
|
- **Current operator problem**: Operators can be told a provider is healthy/ready or permission coverage is absent/present based on local, stale, or ambiguous calculations.
|
||||||
|
- **Existing structure is insufficient because**: Existing structures separately compute health, guidance, capability, required-permission counts, and freshness. At least `ProviderConnectionSurfaceSummary`, `ManagedEnvironmentRequiredPermissionsViewModelBuilder`, `ProviderCapabilityEvaluator`, and `ProviderReadinessResolutionAdapter` can diverge.
|
||||||
|
- **Narrowest correct implementation**: one derived resolver over existing records and verification evidence, adopted by affected provider/readiness consumers, replacing visible labels/counts instead of adding new UI.
|
||||||
|
- **Ownership cost**: one canonical contract, focused tests, migration of existing consumers, and future review discipline that provider readiness changes go through the resolver.
|
||||||
|
- **Alternative intentionally rejected**: local copy/count fixes in Required Permissions and Provider Connections. That would leave stale health/readiness drift elsewhere.
|
||||||
|
- **Release truth**: current-release trust-boundary correction.
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
TenantPilot is pre-production. Do not preserve old `Healthy`, `Ready`, `Present`, fallback, compatibility alias, translation key, fixture, or test behavior when it conflicts with this spec. Canonical replacement wins.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Unit for resolver state/count logic, Feature for DB/scope/RBAC/action semantics, Filament/Livewire for provider pages/actions, Browser for focused end-to-end UI proof.
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence, browser. PostgreSQL only if implementation adds schema or relies on PostgreSQL-specific query/index behavior.
|
||||||
|
- **Why this classification and these lanes are sufficient**: The core risk is deterministic business truth and visible trust-boundary rendering. Unit/Feature tests prove semantics; Filament tests prove actions and visibility; one browser smoke proves the real product surfaces no longer display forbidden labels/states.
|
||||||
|
- **New or expanded test families**: focused `Spec394` provider readiness resolver tests, provider connection/required-permission feature tests, capability-boundary tests, and one browser smoke.
|
||||||
|
- **Fixture / helper cost impact**: moderate. Tests need workspace, managed environment, provider connection, permission evidence, verification timestamps, and actor capabilities. Helpers must stay explicit and not widen default provider setup globally.
|
||||||
|
- **Heavy-family visibility / justification**: one bounded browser smoke only. No broad heavy-governance family is justified.
|
||||||
|
- **Special surface test profile**: provider/integration strategic surface, diagnostic matrix page, dashboard/overview readiness signal.
|
||||||
|
- **Standard-native relief or required special coverage**: standard Filament coverage plus one browser smoke for visible state/count/action behavior.
|
||||||
|
- **Reviewer handoff**: verify no stale provider renders as healthy/ready, counts equal row aggregation, wrong-scope/stale grants fail closed, readonly users cannot run provider actions, raw provider payloads are absent by default, and no compatibility shim remains.
|
||||||
|
- **Budget / baseline / trend impact**: contained feature-local increase. Escalate if implementation creates broad browser/heavy-governance suites or shared expensive setup defaults.
|
||||||
|
- **Escalation needed**: document-in-feature if a narrow local path remains with reason; follow-up-spec if downstream adapters require a broader readiness propagation contract.
|
||||||
|
- **Active feature PR close-out entry**: Provider Freshness / Permission Semantics Guardrail.
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec394`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections tests/Feature/RequiredPermissions tests/Feature/Verification`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec394ProviderFreshnessPermissionSmokeTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## User Scenarios & Testing
|
||||||
|
|
||||||
|
### User Story 1 - Stale verification is not healthy (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace operator, I need stale provider verification to be visibly non-ready so I do not rely on provider data that TenantPilot has not recently verified.
|
||||||
|
|
||||||
|
**Why this priority**: This prevents the most dangerous false-green provider state.
|
||||||
|
|
||||||
|
**Independent Test**: Create a provider connection with `verification_status=healthy`, old `last_health_check_at`, and old permission evidence. The resolver and UI return `Expired` or `Needs attention`, never `Healthy` or `Ready`, and the primary action is to verify provider.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a successful verification older than the freshness window, **When** provider readiness is rendered, **Then** the provider state is `Expired` or `Needs attention`, not `Healthy` or `Ready`.
|
||||||
|
2. **Given** stale permission grants, **When** required permission rows are resolved, **Then** row states are `Expired` and coverage does not count them as granted.
|
||||||
|
|
||||||
|
### User Story 2 - Required permission counts match effective rows (Priority: P1)
|
||||||
|
|
||||||
|
As an operator reviewing permissions, I need the summary counts to match the visible required permission rows so remediation is not misleading.
|
||||||
|
|
||||||
|
**Why this priority**: Ambiguous `Present` counts break trust and can send users to the wrong action.
|
||||||
|
|
||||||
|
**Independent Test**: Seed required permission rows with fresh effective grants, missing rows, blocked rows, expired rows, and unknown rows. The summary counts equal row-state aggregation and no product UI shows `Present 0`.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** six required rows in `Granted` and two in `Missing`, **When** the Required Permissions page renders, **Then** `required_count=8`, `granted_required_count=6`, `missing_required_count=2`, and the row count matches.
|
||||||
|
2. **Given** granted raw rows that are wrong-scope, **When** the resolver runs for the current provider connection, **Then** those rows do not count as granted.
|
||||||
|
|
||||||
|
### User Story 3 - Wrong-scope and unknown grants fail closed (Priority: P1)
|
||||||
|
|
||||||
|
As a security-conscious operator, I need TenantPilot to reject wrong-scope, unknown, or out-of-date permission evidence instead of assuming access is available.
|
||||||
|
|
||||||
|
**Why this priority**: Scope leakage or stale evidence can produce false confidence across workspaces, environments, provider connections, or provider accounts.
|
||||||
|
|
||||||
|
**Independent Test**: Create grants for another workspace, another environment, another provider connection, and a stale verification batch. Current-scope coverage remains `Missing`, `Expired`, or `Unknown`.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a grant belongs to another workspace or environment, **When** current provider readiness is resolved, **Then** the grant is ignored and readiness is not `Ready`.
|
||||||
|
2. **Given** insufficient verification data, **When** readiness is rendered, **Then** the state is `Unknown` or `Needs attention` and no provider-changing action appears for readonly users.
|
||||||
|
|
||||||
|
### User Story 4 - Product surfaces share one provider truth (Priority: P2)
|
||||||
|
|
||||||
|
As an operator moving between dashboard, provider connection, required permissions, and governance work surfaces, I need the provider blocker to be consistent and actionable.
|
||||||
|
|
||||||
|
**Why this priority**: It prevents contradictory signals and reduces search work after the core resolver is correct.
|
||||||
|
|
||||||
|
**Independent Test**: Run focused feature and browser tests across Provider Connections, Required Permissions, Environment Dashboard, Workspace Overview, and Governance Inbox provider-related items. Each uses the same resolver state and recommended action.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a provider is stale, **When** dashboard and provider details render, **Then** both point to verification and neither shows ready/healthy.
|
||||||
|
2. **Given** missing required permissions, **When** dashboard guidance opens Required Permissions, **Then** the target page shows the same missing-permission blocker and canonical counts.
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
- **NFR-394-001**: Resolver decisions must be DB-only during render and must not call Microsoft Graph or any external provider.
|
||||||
|
- **NFR-394-002**: Queries must be scope-safe and deterministic; no implicit database ordering.
|
||||||
|
- **NFR-394-003**: Default UI must remain calm, decision-first, and no more complex than current surfaces.
|
||||||
|
- **NFR-394-004**: Raw provider payloads, tokens, credentials, API response bodies, and grant IDs must not appear in default product UI or audit metadata.
|
||||||
|
- **NFR-394-005**: Filament v5 / Livewire v4.0+ compliance is required. Current repo uses Livewire 4.1.4.
|
||||||
|
- **NFR-394-006**: Provider panel registration remains unchanged in `apps/platform/bootstrap/providers.php`.
|
||||||
|
|
||||||
|
## Data And Truth-Source Requirements
|
||||||
|
|
||||||
|
Prefer existing schema and relations:
|
||||||
|
|
||||||
|
- `provider_connections.verification_status`
|
||||||
|
- `provider_connections.last_health_check_at`
|
||||||
|
- `provider_connections.consent_status`
|
||||||
|
- `provider_connections.is_enabled`
|
||||||
|
- `provider_connections.provider`
|
||||||
|
- `provider_connections.workspace_id`
|
||||||
|
- `provider_connections.managed_environment_id`
|
||||||
|
- existing provider identity metadata fields/casts on `provider_connections`
|
||||||
|
- `managed_environment_permissions.workspace_id`
|
||||||
|
- `managed_environment_permissions.managed_environment_id`
|
||||||
|
- `managed_environment_permissions.permission_key`
|
||||||
|
- `managed_environment_permissions.status`
|
||||||
|
- `managed_environment_permissions.details`
|
||||||
|
- `managed_environment_permissions.last_checked_at`
|
||||||
|
- provider verification `OperationRun` context/report where repo-real
|
||||||
|
|
||||||
|
Potential clean concepts only if existing data cannot represent the contract:
|
||||||
|
|
||||||
|
- `provider_verification_checked_at`
|
||||||
|
- `provider_verification_expires_at`
|
||||||
|
- `provider_verification_state`
|
||||||
|
- `permission_grants.verification_batch_id`
|
||||||
|
- `permission_grants.scope_type`
|
||||||
|
- `permission_grants.scope_id`
|
||||||
|
- `required_permissions.provider_key`
|
||||||
|
- `required_permissions.product_purpose`
|
||||||
|
|
||||||
|
If implementation proves one of these is needed, stop and update spec/plan before adding migrations.
|
||||||
|
|
||||||
|
Provider account/context is not an implicit free-form match. For v1, it is derived from the current `ProviderConnection` identity and same-provider verification context. If existing records cannot distinguish two provider accounts behind the same workspace/environment/provider key, affected permissions must resolve as `Unknown` or the implementation must stop and update spec/plan before adding schema.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- **AC-394-001**: Stale provider is not `Healthy` or `Ready`; primary action is verify/refresh provider.
|
||||||
|
- **AC-394-002**: Fresh provider with all effective required permissions is `Ready`.
|
||||||
|
- **AC-394-003**: Required permission counts match row states and no UI shows `Present 0` while granted rows are visible.
|
||||||
|
- **AC-394-004**: Missing permission blocks readiness and recommends permission remediation.
|
||||||
|
- **AC-394-005**: Wrong-scope grants do not satisfy current required permissions.
|
||||||
|
- **AC-394-006**: Stale grants produce `Expired` permission row state and non-ready provider readiness.
|
||||||
|
- **AC-394-007**: Unknown state fails closed and never renders as ready/healthy.
|
||||||
|
- **AC-394-008**: Readonly users cannot trigger verify/reconnect/manage provider actions and cannot view raw provider technical detail.
|
||||||
|
- **AC-394-009**: Affected pages show one provider readiness/health truth by default.
|
||||||
|
- **AC-394-010**: Affected pages do not gain new cards, sections, evidence/proof links, or long technical tables as part of this spec.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- A stale provider never appears healthy on Provider Connections, Required Permissions, Environment Dashboard, or provider readiness consumers.
|
||||||
|
- Required permission summary counts equal canonical row-state aggregation.
|
||||||
|
- Connected provider state and provider readiness are visibly distinct.
|
||||||
|
- Wrong-scope and stale grants are excluded from effective permission coverage.
|
||||||
|
- Provider-changing actions are capability-gated.
|
||||||
|
- No raw provider payload or technical permission dump appears by default.
|
||||||
|
- No legacy compatibility shim preserves old provider health or `Present` semantics.
|
||||||
|
|
||||||
|
## Regression Risks
|
||||||
|
|
||||||
|
- Existing tests may expect old `Healthy` behavior. Mitigation: update tests to canonical behavior without weakening the resolver.
|
||||||
|
- Fixture inconsistencies may appear when counts change. Mitigation: fix fixtures to represent canonical grants and scope.
|
||||||
|
- Downstream surfaces may lose `Ready` states. Mitigation: if provider state is stale or incomplete, losing `Ready` is correct.
|
||||||
|
- Readonly users may lose technical detail. Mitigation: product-safe summary remains visible where authorized; technical detail remains manage/technical-capability gated.
|
||||||
|
- Provider labels may become too technical. Mitigation: product labels first, raw provider names secondary/internal only.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- TenantPilot remains pre-production for this feature area; no legacy customer data compatibility is required.
|
||||||
|
- Existing `ProviderConnection`, `ManagedEnvironmentPermission`, and verification report/OperationRun data can represent the v1 contract unless implementation proves otherwise.
|
||||||
|
- Microsoft remains the first provider, but product-facing readiness vocabulary must stay provider-neutral.
|
||||||
|
- No new dashboard, onboarding wizard, provider integration, or customer-facing provider detail is required.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None blocking preparation. During implementation, if current schema cannot distinguish fresh verification batch scope from stale permission rows, update spec/plan before adding schema.
|
||||||
|
|
||||||
|
## Follow-Up Spec Candidates
|
||||||
|
|
||||||
|
- Provider Onboarding & Permissions Resolution Adapter: guided setup/remediation flow after canonical readiness semantics are stable.
|
||||||
|
- Product Surface Contract Enforcement provider pass: broader cross-surface enforcement if additional downstream consumers keep local provider truth.
|
||||||
|
- Provider secondary-action density cleanup: optional Spec 353 follow-up if the canonical state change reveals action clutter.
|
||||||
|
|
||||||
|
## Required Implementation Report
|
||||||
|
|
||||||
|
Later implementation must report:
|
||||||
|
|
||||||
|
1. Files changed.
|
||||||
|
2. Resolver/API created or consolidated.
|
||||||
|
3. Old local provider health/readiness logic removed or replaced.
|
||||||
|
4. Required permission count logic source.
|
||||||
|
5. Tests added/updated.
|
||||||
|
6. Browser flows run.
|
||||||
|
7. Evidence that stale verification no longer renders `Healthy` or `Ready`.
|
||||||
|
8. Evidence that required permission counts match rows.
|
||||||
|
9. Evidence that wrong-scope grants are excluded.
|
||||||
|
10. Confirmation that no legacy compatibility shim was added.
|
||||||
|
11. Confirmation that visible UI complexity did not increase.
|
||||||
|
12. Remaining known unrelated failures, if any.
|
||||||
201
specs/394-provider-freshness-permission-semantics/tasks.md
Normal file
201
specs/394-provider-freshness-permission-semantics/tasks.md
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
# Tasks: Spec 394 - Provider Freshness & Permission Semantics v1
|
||||||
|
|
||||||
|
**Input**: `specs/394-provider-freshness-permission-semantics/spec.md` and `plan.md`
|
||||||
|
**Prerequisites**: Spec artifacts prepared; implementation must start from repo-truth verification and must not modify completed context specs.
|
||||||
|
**Tests**: Required. This is a provider trust-boundary change with Unit, Feature/Filament, and bounded Browser proof.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [ ] Lane assignment is confirmed as narrow: Unit for resolver logic, Feature/Filament for DB/scope/RBAC/UI action semantics, Browser for one focused visible provider-readiness smoke.
|
||||||
|
- [ ] New or changed tests stay in focused Spec394/provider families; no broad heavy-governance suite is added by default.
|
||||||
|
- [ ] Shared helpers, factories, provider setup, workspace membership, session state, and capability context stay explicit and cheap by default.
|
||||||
|
- [ ] Planned validation commands cover the changed behavior without pulling unrelated suite cost.
|
||||||
|
- [ ] PostgreSQL lane is added only if schema, partial indexes, locks, JSONB, or PostgreSQL-specific query behavior are introduced.
|
||||||
|
- [ ] Browser coverage is one named Spec394 smoke plus exact existing affected browser files where repo-real.
|
||||||
|
- [ ] Any material budget, baseline, trend, or escalation note is recorded in the implementation report.
|
||||||
|
|
||||||
|
## Phase 1: Repo Truth And Provider Semantics Inventory
|
||||||
|
|
||||||
|
**Purpose**: Map current local provider truth before changing behavior.
|
||||||
|
|
||||||
|
- [ ] T001 Re-read `specs/394-provider-freshness-permission-semantics/spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
- [ ] T002 Re-read completed context specs as read-only inputs only: `specs/281-provider-connection-scope`, `specs/283-provider-capability-registry`, `specs/294-provider-verification-runtime-semantics`, `specs/339-provider-connection-scope-hardening`, `specs/353-provider-connections-resolution-guidance-v1`, and `specs/381-provider-resource-identity-binding`.
|
||||||
|
- [ ] T003 Confirm current branch and dirty state with `git status --short --branch` and `git log -1 --oneline`.
|
||||||
|
- [ ] T004 Inventory provider state selectors in `apps/platform/app`, `apps/platform/resources`, `apps/platform/routes`, `apps/platform/tests`, config, and localization files using search terms from the spec: provider health, healthy, readiness, required permissions, present, granted, missing, verification, stale, freshness, admin consent, Graph permission, OAuth scope, provider connection.
|
||||||
|
- [ ] T005 Record inventory in the implementation report: file, current logic, visible label, count source, provider scope, freshness source, permission source, authorization rule, stale-as-healthy risk, row/count mismatch risk, and migration decision.
|
||||||
|
- [ ] T006 Inspect `apps/platform/app/Models/ProviderConnection.php` and `apps/platform/app/Models/ManagedEnvironmentPermission.php` for existing fields and casts usable by the resolver.
|
||||||
|
- [ ] T007 Inspect `apps/platform/app/Services/Intune/ManagedEnvironmentPermissionService.php` and `apps/platform/app/Services/Intune/ManagedEnvironmentRequiredPermissionsViewModelBuilder.php` for count, grant, filter, freshness, and copy semantics.
|
||||||
|
- [ ] T008 Inspect `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/ResolutionGuidance/Adapters/ProviderReadinessResolutionAdapter.php`, and `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php` for current readiness/freshness duplication.
|
||||||
|
- [ ] T009 Inspect Provider Connections, Required Permissions, Environment Dashboard, Workspace Overview, Governance Inbox, restore/evidence/review readiness consumers, and support diagnostics for provider state display or action links.
|
||||||
|
- [ ] T010 Confirm no migration, package, env var, queue, scheduler, storage topology, Graph scope, panel-provider, route family, onboarding wizard, new dashboard/card, or customer-facing provider detail is required; stop and update spec/plan if false.
|
||||||
|
- [ ] T011 Confirm Filament v5 / Livewire v4.0+ compliance and no Filament v3/v4 or Livewire v3 APIs.
|
||||||
|
- [ ] T012 Confirm panel provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||||
|
- [ ] T013 Confirm `ProviderConnectionResource` remains non-globally-searchable and no global search participation is added.
|
||||||
|
|
||||||
|
## Phase 2: Canonical Resolver Contract And Unit Tests
|
||||||
|
|
||||||
|
**Purpose**: Prove provider readiness semantics before replacing surfaces.
|
||||||
|
|
||||||
|
- [ ] T014 Add focused unit tests for the canonical provider readiness resolver under `apps/platform/tests/Unit/Providers/Spec394ProviderReadinessResolverTest.php` or the nearest repo-consistent provider test namespace.
|
||||||
|
- [ ] T015 [P] Test fresh verification plus all required permissions effective returns `Ready`.
|
||||||
|
- [ ] T016 [P] Test stale verification plus old grants returns `Expired` or non-ready, never `Healthy` or `Ready`.
|
||||||
|
- [ ] T017 [P] Test verification failed returns `Failed` and not ready.
|
||||||
|
- [ ] T018 [P] Test no verification returns `Unknown` and not ready.
|
||||||
|
- [ ] T019 [P] Test connected provider without verification returns `Unknown` or `Needs attention`, not ready.
|
||||||
|
- [ ] T020 [P] Test missing required permission returns `Needs attention` and not ready.
|
||||||
|
- [ ] T021 [P] Test blocked/admin consent denied returns `Blocked`.
|
||||||
|
- [ ] T022 [P] Test wrong workspace grant does not satisfy a permission.
|
||||||
|
- [ ] T023 [P] Test wrong environment grant does not satisfy a permission.
|
||||||
|
- [ ] T024 [P] Test wrong provider connection grant does not satisfy a permission.
|
||||||
|
- [ ] T025 [P] Test stale verification grant produces `Expired` permission row state.
|
||||||
|
- [ ] T026 [P] Test count invariant: `required_count = granted + missing + blocked + expired + unknown`.
|
||||||
|
- [ ] T027 [P] Test deterministic ordering when multiple grants exist.
|
||||||
|
- [ ] T028 [P] Test actor without manage capability receives no provider-changing action.
|
||||||
|
- [ ] T029 [P] Test actor without technical permission receives no raw technical payload/detail action.
|
||||||
|
- [ ] T030 Implement or consolidate `ProviderReadinessResolver` in the narrowest repo-consistent namespace, likely under `apps/platform/app/Support/Providers` or `apps/platform/app/Services/Providers`.
|
||||||
|
- [ ] T031 Implement resolver result, aggregate result, and permission row shapes as derived contracts without adding persisted state by default.
|
||||||
|
- [ ] T032 Ensure resolver performs DB-only scoped decisions and no Graph/provider calls during render.
|
||||||
|
- [ ] T033 Ensure resolver exposes canonical count fields and row states from the same result object/array.
|
||||||
|
|
||||||
|
## Phase 3: Required Permissions Page Counts And Rows
|
||||||
|
|
||||||
|
**Purpose**: Replace ambiguous `Present` semantics with canonical row/count behavior.
|
||||||
|
|
||||||
|
- [ ] T034 Add or update Feature/Filament tests for `EnvironmentRequiredPermissions` canonical counts and row states.
|
||||||
|
- [ ] T035 Test Required Permissions page does not show `Present 0` when grants are effective.
|
||||||
|
- [ ] T036 Test stale verification shows `Expired` rows or verification-needed copy and does not count stale grants as granted.
|
||||||
|
- [ ] T037 Test missing required permission produces actionable copy and correct next action.
|
||||||
|
- [ ] T038 Update `ManagedEnvironmentRequiredPermissionsViewModelBuilder` to consume or delegate to the canonical resolver for product-facing counts/rows.
|
||||||
|
- [ ] T039 Update `apps/platform/resources/views/filament/pages/environment-required-permissions.blade.php` labels from `Present`/ambiguous counts to canonical `Required`, `Granted`, `Missing`, `Blocked`, `Expired`, and `Unknown` labels.
|
||||||
|
- [ ] T040 Keep raw provider permission names secondary/technical, not primary product labels.
|
||||||
|
- [ ] T041 Ensure filter behavior remains useful without preserving old `present` product copy; update filter state only if needed and remove old tests/fixtures that assert misleading labels.
|
||||||
|
- [ ] T042 Ensure copy payloads still copy missing-only permission names and do not expose raw JSON or technical dumps.
|
||||||
|
|
||||||
|
## Phase 4: Provider Connections And Provider Guidance Consumers
|
||||||
|
|
||||||
|
**Purpose**: Stop Provider Connections from mapping stale/partial state to `Healthy` or `Ready`.
|
||||||
|
|
||||||
|
- [ ] T043 Add or update Feature/Filament tests proving Provider Connection list/view/edit do not show `Healthy` or `Ready` for stale verification.
|
||||||
|
- [ ] T044 Add or update tests proving fresh verification plus full effective permissions shows `Ready`.
|
||||||
|
- [ ] T045 Add or update tests proving connected-only provider state is not enough for readiness.
|
||||||
|
- [ ] T046 Update `ProviderConnectionSurfaceSummary` to delegate readiness and primary provider capability summary to the canonical resolver or remove its independent readiness mapping.
|
||||||
|
- [ ] T047 Update `ProviderReadinessResolutionAdapter` to consume resolver output or delegate its stale/missing/failed decisions to the resolver while preserving Spec 353 guidance hierarchy.
|
||||||
|
- [ ] T048 Update Provider Connection Resource/Page display fields, badges, and action visibility to use resolver output.
|
||||||
|
- [ ] T049 Ensure provider verification primary action is shown only when actor has the correct capability and verification is the correct recommended action.
|
||||||
|
- [ ] T050 Ensure existing high-impact/destructive provider actions retain `->action(...)`, confirmation where required, authorization, audit logging, and tests.
|
||||||
|
- [ ] T051 Update `ProviderVerificationStatusBadge` or consuming code so stale `healthy` does not render as product `Healthy`; preserve internal status only where explicitly technical.
|
||||||
|
|
||||||
|
## Phase 5: Dashboard, Workspace Overview, Governance Inbox, And Downstream Readiness
|
||||||
|
|
||||||
|
**Purpose**: Ensure direct provider readiness consumers share canonical provider truth.
|
||||||
|
|
||||||
|
- [ ] T052 Add or update Environment Dashboard tests proving stale provider cannot appear ready/healthy and dashboard primary action points to verification/remediation.
|
||||||
|
- [ ] T053 Add or update Workspace Overview tests where repo-real provider/readiness signals exist.
|
||||||
|
- [ ] T054 Add or update Governance Inbox tests where provider-related items exist.
|
||||||
|
- [ ] T055 Add or update restore/evidence/review readiness tests only where those surfaces directly consume provider state.
|
||||||
|
- [ ] T056 Update `EnvironmentDashboardSummaryBuilder` provider blocker/readiness logic to consume resolver output rather than local required-permission counts where applicable.
|
||||||
|
- [ ] T057 Update `WorkspaceOverviewBuilder` provider/readiness aggregation where repo-real to consume resolver aggregate child results, scoped only to actor-authorized environments.
|
||||||
|
- [ ] T058 Update `GovernanceInboxSectionBuilder` provider-related items where repo-real.
|
||||||
|
- [ ] T059 Update direct downstream readiness consumers such as restore/evidence/review readiness adapters only enough to prevent provider blockers from being overridden.
|
||||||
|
- [ ] T060 Document any remaining local provider logic path in the implementation report with reason, risk, and follow-up.
|
||||||
|
|
||||||
|
## Phase 6: Scope Matching, RBAC, And Technical Detail Gating
|
||||||
|
|
||||||
|
**Purpose**: Make effective permissions scope-safe and action-safe.
|
||||||
|
|
||||||
|
- [ ] T061 Add tests proving wrong workspace/environment/provider connection/provider account/provider namespace grants are excluded, using the current `ProviderConnection` identity and same-provider verification context as the v1 provider-account/context source. If repo data cannot express a required provider-account distinction, stop and update spec/plan before implementation.
|
||||||
|
- [ ] T062 Add tests proving actor without view capability cannot view provider detail result or route.
|
||||||
|
- [ ] T063 Add tests proving readonly/view-only actors can see product-safe summary but cannot trigger verify/reconnect/manage provider actions.
|
||||||
|
- [ ] T064 Add tests proving manage-capable actors can access supported verify/reconnect actions.
|
||||||
|
- [ ] T065 Add tests proving raw provider payload, grant IDs, OAuth token details, and Graph/API dumps are not visible in default product UI.
|
||||||
|
- [ ] T066 Ensure resolver receives actor context and computes `can_view_technical_detail` and `can_manage_provider` from existing policy/capability paths.
|
||||||
|
- [ ] T067 Ensure UI surfaces use `UiEnforcement` or `WorkspaceUiEnforcement` where appropriate and server-side Gate/Policy checks still enforce actions.
|
||||||
|
- [ ] T068 Ensure technical detail falls back to stricter provider manage capability if no granular technical capability exists.
|
||||||
|
- [ ] T069 Ensure audit metadata for any touched provider-changing action remains redacted and contains no secrets or raw payloads.
|
||||||
|
|
||||||
|
## Phase 7: Legacy Label, Fixture, Translation, And Guard Cleanup
|
||||||
|
|
||||||
|
**Purpose**: Remove old semantics rather than compatibility-shimming them.
|
||||||
|
|
||||||
|
- [ ] T070 Search for product-facing `Present`, `Present 0`, `Provider Healthy`, `Healthy`, `Ready`, `OK`, `Current`, `permission count`, and raw grant labels in provider/readiness surfaces.
|
||||||
|
- [ ] T071 Replace or remove legacy product labels and stale translation keys that preserve old semantics.
|
||||||
|
- [ ] T072 Update fixtures and factories that create granted rows without fresh verification semantics or correct scope.
|
||||||
|
- [ ] T073 Update old tests that assert misleading provider health, stale readiness, `Present` counts, or wrong-scope grants.
|
||||||
|
- [ ] T074 Add or update guard tests where practical to ensure stale verification does not map to healthy/ready through the resolver.
|
||||||
|
- [ ] T075 Add or update count-invariant guard tests where practical.
|
||||||
|
- [ ] T076 Add string guards for deprecated ambiguous labels only if they are not too noisy and do not block legitimate technical/admin documentation.
|
||||||
|
- [ ] T077 Do not add aliases, fallback readers, dual-write logic, compatibility shims, old route aliases, or tests preserving old behavior.
|
||||||
|
|
||||||
|
## Phase 8: Browser Smoke
|
||||||
|
|
||||||
|
**Purpose**: Prove visible product behavior on real surfaces.
|
||||||
|
|
||||||
|
- [ ] T078 Add or update `apps/platform/tests/Browser/Spec394ProviderFreshnessPermissionSmokeTest.php` using existing provider/required-permission fixture helpers where practical.
|
||||||
|
- [ ] T079 Browser state: stale provider is not shown as `Healthy` or `Ready`.
|
||||||
|
- [ ] T080 Browser state: fresh fully-permissioned provider is shown as `Ready`.
|
||||||
|
- [ ] T081 Browser state: Required Permissions counts match visible rows.
|
||||||
|
- [ ] T082 Browser state: `Present 0` or equivalent ambiguous legacy count is absent.
|
||||||
|
- [ ] T083 Browser state: missing permissions produce actionable copy.
|
||||||
|
- [ ] T084 Browser state: readonly user cannot trigger provider management actions.
|
||||||
|
- [ ] T085 Browser state: manage-capable user can access verify/reconnect where supported.
|
||||||
|
- [ ] T086 Browser state: no raw provider payload/default technical permission dump is visible.
|
||||||
|
- [ ] T087 Browser state: no 500/Livewire/Filament/console errors in affected flows.
|
||||||
|
- [ ] T088 Run existing affected browser tests if present: `Spec353ProviderReadinessGuidanceSmokeTest`, provider connection smoke tests, Required Permissions smoke tests, Environment Dashboard smoke tests, Workspace Overview smoke tests, Governance Inbox smoke tests.
|
||||||
|
|
||||||
|
## Phase 9: Validation, UI Coverage, And Close-Out
|
||||||
|
|
||||||
|
**Purpose**: Prove the implementation and record the trust-boundary outcome.
|
||||||
|
|
||||||
|
- [ ] T089 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec394`.
|
||||||
|
- [ ] T090 Run targeted provider/permission/verification feature tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections tests/Feature/RequiredPermissions tests/Feature/Verification`.
|
||||||
|
- [ ] T091 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec394ProviderFreshnessPermissionSmokeTest.php`.
|
||||||
|
- [ ] T092 Run exact existing affected browser tests discovered during implementation when they cover changed flows.
|
||||||
|
- [ ] T093 Update `docs/ui-ux-enterprise-audit/page-reports/ui-009-provider-connections.md` and `ui-077-required-permissions.md` only if visible page behavior materially changed, or document explicit no-route/no-archetype/no-count-impact decisions in the implementation report.
|
||||||
|
- [ ] T094 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- [ ] T095 Run `git diff --check`.
|
||||||
|
- [ ] T096 Confirm no migrations, packages, env vars, queues, scheduler, storage topology, Graph contracts/calls, panel providers, route family, onboarding wizard, dashboard/card, raw provider panel, or legacy compatibility path were added unless spec/plan were updated first.
|
||||||
|
- [ ] T097 Complete the required implementation report with files changed, resolver/API, replaced local logic, count source, tests, browser flows, stale-not-healthy evidence, count-row evidence, wrong-scope evidence, no compatibility shim, no UI complexity increase, and unrelated failures.
|
||||||
|
- [ ] T098 Complete human product sanity check over Provider Connections, Required Permissions, Environment Dashboard provider area, Workspace Overview provider area, and Governance Inbox provider-related item before marking Spec 394 done.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Phase 1 must complete before runtime implementation.
|
||||||
|
- Phase 2 resolver tests should land before or alongside resolver implementation.
|
||||||
|
- Phase 3 and Phase 4 depend on resolver output.
|
||||||
|
- Phase 5 depends on resolver adoption in direct provider surfaces.
|
||||||
|
- Phase 6 can run alongside Phases 3-5 after the resolver accepts actor context.
|
||||||
|
- Phase 8 runs after targeted tests and UI label/action changes.
|
||||||
|
- Phase 9 closes the feature.
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
- T006-T009 can be split by repo surface during inspection.
|
||||||
|
- T015-T029 can be implemented in parallel as independent resolver test cases.
|
||||||
|
- T034-T037 can run in parallel with T043-T045 after resolver result shape is stable.
|
||||||
|
- T052-T055 can be split by downstream surface once direct provider surfaces are updated.
|
||||||
|
- T061-T065 can run in parallel with UI adoption to lock RBAC and raw-detail boundaries.
|
||||||
|
|
||||||
|
## Non-Goals / Stop Conditions
|
||||||
|
|
||||||
|
- Stop if implementation requires a new persisted verification batch, permission grant table, provider readiness table, provider-account identifier, or status ledger; update spec/plan first.
|
||||||
|
- Stop if a page-local product-facing provider health calculation appears necessary; fold it into the resolver or document a bounded exception.
|
||||||
|
- Stop if the only way to keep a test green is to preserve stale-as-healthy, `Present`, wrong-scope grant, or stale-grant behavior.
|
||||||
|
- Stop if scope expands into new provider integration, OAuth redesign, onboarding wizard, dashboard/card, customer-facing provider detail, raw permission panel, or broad Product Surface Contract Enforcement.
|
||||||
|
- Do not rewrite, normalize, uncheck, or remove implementation history from completed Specs 281, 283, 294, 339, 353, or 381.
|
||||||
|
|
||||||
|
## Required Final Report Content For Later Implementation
|
||||||
|
|
||||||
|
When implementation later completes, report:
|
||||||
|
|
||||||
|
- Files changed.
|
||||||
|
- Resolver/API created or consolidated.
|
||||||
|
- Old local provider health/readiness logic removed or replaced.
|
||||||
|
- Required permission count logic source.
|
||||||
|
- Tests added/updated.
|
||||||
|
- Browser flows run.
|
||||||
|
- Evidence that stale verification no longer renders `Healthy` or `Ready`.
|
||||||
|
- Evidence that required permission counts match rows.
|
||||||
|
- Evidence that wrong-scope grants are excluded.
|
||||||
|
- Confirmation that no legacy compatibility shim was added.
|
||||||
|
- Confirmation that visible UI complexity did not increase.
|
||||||
|
- Remaining known unrelated failures, if any.
|
||||||
Loading…
Reference in New Issue
Block a user