feat: complete workspace-first environment routing cutover #340
@ -63,8 +63,10 @@ public function getWorkspaces(): Collection
|
|||||||
->where('user_id', $user->getKey());
|
->where('user_id', $user->getKey());
|
||||||
})
|
})
|
||||||
->whereNull('archived_at')
|
->whereNull('archived_at')
|
||||||
|
->whereNull('closed_at')
|
||||||
->withCount(['tenants' => function ($query): void {
|
->withCount(['tenants' => function ($query): void {
|
||||||
$query->where('lifecycle_status', 'active');
|
$query->where('lifecycle_status', 'active')
|
||||||
|
->whereNull('removed_from_workspace_at');
|
||||||
}])
|
}])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
@ -94,7 +96,7 @@ public function selectWorkspace(int $workspaceId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($workspace->archived_at)) {
|
if (! $workspace->isSelectableAsContext()) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -255,6 +255,21 @@ public function content(Schema $schema): Schema
|
|||||||
->content(fn (): string => $this->commercialPostureReasonText())
|
->content(fn (): string => $this->commercialPostureReasonText())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
|
Section::make('Workspace lifecycle')
|
||||||
|
->description('Read-only workspace closure posture. Closed workspaces keep history visible but block new tenant and workspace mutations.')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('workspace_closure_posture')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->content(fn (): string => $this->workspace->isClosed() ? 'Closed' : 'Open'),
|
||||||
|
Placeholder::make('workspace_closed_at')
|
||||||
|
->label('Closed at')
|
||||||
|
->content(fn (): string => $this->workspace->closed_at?->toDayDateTimeString() ?? 'Not closed'),
|
||||||
|
Placeholder::make('workspace_closure_reason')
|
||||||
|
->label('Closure reason')
|
||||||
|
->content(fn (): string => $this->workspace->closureReason() ?? 'Not closed')
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
Section::make('Support access approval')
|
Section::make('Support access approval')
|
||||||
->description('Review current support-access posture and decide pending workspace recovery requests.')
|
->description('Review current support-access posture and decide pending workspace recovery requests.')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@ -1740,6 +1755,7 @@ private function currentUserCanManage(): bool
|
|||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
return $resolver->isMember($user, $this->workspace)
|
return $resolver->isMember($user, $this->workspace)
|
||||||
|
&& ! $this->workspace->isClosed()
|
||||||
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1755,6 +1771,7 @@ private function currentUserCanApproveSupportAccess(): bool
|
|||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
return $resolver->isMember($user, $this->workspace)
|
return $resolver->isMember($user, $this->workspace)
|
||||||
|
&& ! $this->workspace->isClosed()
|
||||||
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)
|
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)
|
||||||
&& $resolver->getRole($user, $this->workspace) === WorkspaceRole::Owner;
|
&& $resolver->getRole($user, $this->workspace) === WorkspaceRole::Owner;
|
||||||
}
|
}
|
||||||
@ -1785,5 +1802,11 @@ private function authorizeWorkspaceManage(User $user): void
|
|||||||
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->workspace->isClosed()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'workspace' => 'This workspace is closed. Reopen it before changing workspace settings.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,6 +93,21 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
|
|||||||
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
|
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||||
|
*/
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
TenantDashboardContextChips::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaderWidgetsColumns(): int|array
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<class-string<Widget> | WidgetConfiguration>
|
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||||
*/
|
*/
|
||||||
@ -100,7 +115,6 @@ public function getWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TenantTriageArrivalContinuity::class,
|
TenantTriageArrivalContinuity::class,
|
||||||
TenantDashboardContextChips::class,
|
|
||||||
DashboardKpis::class,
|
DashboardKpis::class,
|
||||||
TenantDashboardOverview::class,
|
TenantDashboardOverview::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1473,16 +1473,16 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr
|
|||||||
'verification_state' => $this->stringValue($connection->verification_status),
|
'verification_state' => $this->stringValue($connection->verification_status),
|
||||||
'readiness_summary' => 'Target scope needs review',
|
'readiness_summary' => 'Target scope needs review',
|
||||||
'target_scope_summary' => 'Target scope needs review',
|
'target_scope_summary' => 'Target scope needs review',
|
||||||
|
'provider_context' => [
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'details' => [],
|
||||||
|
],
|
||||||
'contextual_identity_line' => null,
|
'contextual_identity_line' => null,
|
||||||
'is_enabled' => (bool) $connection->is_enabled,
|
'is_enabled' => (bool) $connection->is_enabled,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_merge($summary->toArray(), [
|
return $summary->toArray();
|
||||||
'target_scope_summary' => $summary->targetScopeSummary(),
|
|
||||||
'contextual_identity_line' => $summary->contextualIdentityLine(),
|
|
||||||
'is_enabled' => (bool) $connection->is_enabled,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2658,7 +2658,10 @@ private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $
|
|||||||
'shared_label' => 'Target scope',
|
'shared_label' => 'Target scope',
|
||||||
'shared_help_text' => 'The platform scope this provider connection represents.',
|
'shared_help_text' => 'The platform scope this provider connection represents.',
|
||||||
],
|
],
|
||||||
'provider_identity_context' => [],
|
'provider_context' => [
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'details' => [],
|
||||||
|
],
|
||||||
], $extra);
|
], $extra);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1657,6 +1657,21 @@ private static function targetScopeDisplay(OperationRun $record): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scopeDisplayName = $targetScope['scope_display_name'] ?? null;
|
||||||
|
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
|
||||||
|
$scopeDisplayName = is_string($scopeDisplayName) ? trim($scopeDisplayName) : null;
|
||||||
|
$scopeIdentifier = is_string($scopeIdentifier) ? trim($scopeIdentifier) : null;
|
||||||
|
|
||||||
|
if ($scopeDisplayName !== null && $scopeDisplayName !== '') {
|
||||||
|
return $scopeIdentifier !== null && $scopeIdentifier !== '' && $scopeIdentifier !== $scopeDisplayName
|
||||||
|
? "{$scopeDisplayName} ({$scopeIdentifier})"
|
||||||
|
: $scopeDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scopeIdentifier !== null && $scopeIdentifier !== '') {
|
||||||
|
return $scopeIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
||||||
|
|||||||
@ -506,7 +506,7 @@ private static function targetScopeSummary(?ProviderConnection $record): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function providerIdentityContext(?ProviderConnection $record): ?string
|
private static function providerContextSummary(?ProviderConnection $record): ?string
|
||||||
{
|
{
|
||||||
if (! $record instanceof ProviderConnection) {
|
if (! $record instanceof ProviderConnection) {
|
||||||
return null;
|
return null;
|
||||||
@ -539,7 +539,10 @@ public static function targetScopeAuditMetadata(ProviderConnection $record, arra
|
|||||||
'shared_label' => 'Target scope',
|
'shared_label' => 'Target scope',
|
||||||
'shared_help_text' => static::targetScopeHelpText(),
|
'shared_help_text' => static::targetScopeHelpText(),
|
||||||
],
|
],
|
||||||
'provider_identity_context' => [],
|
'provider_context' => [
|
||||||
|
'provider' => (string) $record->provider,
|
||||||
|
'details' => [],
|
||||||
|
],
|
||||||
], $extra);
|
], $extra);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -681,9 +684,9 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->label('Migration review')
|
->label('Migration review')
|
||||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||||
Infolists\Components\TextEntry::make('provider_identity_context')
|
Infolists\Components\TextEntry::make('provider_context')
|
||||||
->label('Provider identity details')
|
->label('Provider context')
|
||||||
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
|
->state(fn (ProviderConnection $record): ?string => static::providerContextSummary($record))
|
||||||
->placeholder('n/a')
|
->placeholder('n/a')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Infolists\Components\TextEntry::make('last_error_reason_code')
|
Infolists\Components\TextEntry::make('last_error_reason_code')
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
use App\Services\Tenants\TenantActionPolicySurface;
|
use App\Services\Tenants\TenantActionPolicySurface;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
|
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
@ -61,6 +62,7 @@
|
|||||||
use App\Support\Tenants\TenantOperabilityOutcome;
|
use App\Support\Tenants\TenantOperabilityOutcome;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
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;
|
||||||
@ -147,6 +149,8 @@ public static function canEdit(Model $record): bool
|
|||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return $record instanceof ManagedEnvironment
|
return $record instanceof ManagedEnvironment
|
||||||
|
&& ! $record->isRemovedFromWorkspace()
|
||||||
|
&& ! $record->workspace?->isClosed()
|
||||||
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,6 +166,8 @@ public static function canDelete(Model $record): bool
|
|||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return $record instanceof ManagedEnvironment
|
return $record instanceof ManagedEnvironment
|
||||||
|
&& ! $record->isRemovedFromWorkspace()
|
||||||
|
&& ! $record->workspace?->isClosed()
|
||||||
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,7 +201,9 @@ public static function makeAdminConsentAction(): Actions\Action
|
|||||||
->label('Grant admin consent')
|
->label('Grant admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
->url(fn (ManagedEnvironment $record): string => static::adminConsentUrl($record) ?? '#')
|
->url(fn (ManagedEnvironment $record): string => static::adminConsentUrl($record) ?? '#')
|
||||||
->visible(fn (ManagedEnvironment $record): bool => static::adminConsentUrl($record) !== null)
|
->visible(fn (ManagedEnvironment $record): bool => static::adminConsentUrl($record) !== null
|
||||||
|
&& ! $record->isRemovedFromWorkspace()
|
||||||
|
&& ! $record->workspace?->isClosed())
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
@ -400,9 +408,115 @@ public static function makeArchiveTenantAction(TenantActionSurface $surface, ?st
|
|||||||
return $builder->apply();
|
return $builder->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function makeRemoveTenantFromWorkspaceAction(?string $permissionTooltip = null): Actions\Action
|
||||||
|
{
|
||||||
|
$builder = UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('remove_from_workspace')
|
||||||
|
->label('Remove tenant')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-no-symbol')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Remove tenant from workspace')
|
||||||
|
->modalDescription('The tenant remains available for audit, operation history, evidence, and administrative inspection, but it is no longer selectable as active tenant context.')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Textarea::make('removal_reason')
|
||||||
|
->label('Removal reason')
|
||||||
|
->rows(4)
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(2000),
|
||||||
|
])
|
||||||
|
->visible(fn (ManagedEnvironment $record): bool => static::tenantWorkspaceRemovalActionVisible($record))
|
||||||
|
->disabled(fn (ManagedEnvironment $record): bool => (bool) $record->workspace?->isClosed())
|
||||||
|
->tooltip(fn (ManagedEnvironment $record): ?string => $record->workspace?->isClosed()
|
||||||
|
? 'Closed workspaces are read-only. Reopen the workspace before removing tenants.'
|
||||||
|
: null)
|
||||||
|
->action(function (ManagedEnvironment $record, array $data, WorkspaceLifecycleService $service): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($record)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->removeTenantFromWorkspace($record, $user, (string) ($data['removal_reason'] ?? ''));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Tenant removed from workspace')
|
||||||
|
->body('The tenant remains available for administrative inspection and historical evidence, but it is no longer selectable as active context.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE);
|
||||||
|
|
||||||
|
if ($permissionTooltip !== null && $permissionTooltip !== '') {
|
||||||
|
$builder->tooltip($permissionTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function makeRestoreTenantToWorkspaceAction(?string $permissionTooltip = null): Actions\Action
|
||||||
|
{
|
||||||
|
$builder = UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('restore_to_workspace')
|
||||||
|
->label('Restore tenant')
|
||||||
|
->color('success')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Restore tenant to workspace')
|
||||||
|
->modalDescription('Restoring the tenant makes it eligible for normal workspace tenant selection and new tenant operations again, subject to its lifecycle and RBAC.')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Textarea::make('restore_reason')
|
||||||
|
->label('Restore reason')
|
||||||
|
->rows(4)
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(2000),
|
||||||
|
])
|
||||||
|
->visible(fn (ManagedEnvironment $record): bool => static::tenantWorkspaceRestoreActionVisible($record))
|
||||||
|
->disabled(fn (ManagedEnvironment $record): bool => (bool) $record->workspace?->isClosed())
|
||||||
|
->tooltip(fn (ManagedEnvironment $record): ?string => $record->workspace?->isClosed()
|
||||||
|
? 'Closed workspaces are read-only. Reopen the workspace before restoring tenants.'
|
||||||
|
: null)
|
||||||
|
->action(function (ManagedEnvironment $record, array $data, WorkspaceLifecycleService $service): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($record)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->restoreTenantToWorkspace($record, $user, (string) ($data['restore_reason'] ?? ''));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Tenant restored to workspace')
|
||||||
|
->body('The tenant can be selected again when its lifecycle and RBAC allow it.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE);
|
||||||
|
|
||||||
|
if ($permissionTooltip !== null && $permissionTooltip !== '') {
|
||||||
|
$builder->tooltip($permissionTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->apply();
|
||||||
|
}
|
||||||
|
|
||||||
private static function syncActionVisible(ManagedEnvironment $record): bool
|
private static function syncActionVisible(ManagedEnvironment $record): bool
|
||||||
{
|
{
|
||||||
if (! $record->isActive()) {
|
if (! static::tenantSetupMutationVisible($record)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -635,6 +749,7 @@ public static function getEloquentQuery(): Builder
|
|||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->whereIn('id', $tenantIds)
|
->whereIn('id', $tenantIds)
|
||||||
|
->with('workspace')
|
||||||
->withCount('policies')
|
->withCount('policies')
|
||||||
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
||||||
}
|
}
|
||||||
@ -748,6 +863,17 @@ public static function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
||||||
->description(fn (ManagedEnvironment $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
|
->description(fn (ManagedEnvironment $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('workspace_posture')
|
||||||
|
->label('Workspace posture')
|
||||||
|
->badge()
|
||||||
|
->state(fn (ManagedEnvironment $record): string => app(WorkspaceLifecycleService::class)->tenantPosture($record))
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->description(fn (ManagedEnvironment $record): ?string => $record->isRemovedFromWorkspace()
|
||||||
|
? 'Hidden from active tenant context; historical records remain available.'
|
||||||
|
: null),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->since()
|
->since()
|
||||||
@ -869,6 +995,7 @@ public static function table(Table $table): Table
|
|||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
->icon('heroicon-o-pencil-square')
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->visible(fn (ManagedEnvironment $record): bool => static::canEdit($record))
|
||||||
->url(fn (ManagedEnvironment $record) => static::getUrl('edit', ['record' => $record]))
|
->url(fn (ManagedEnvironment $record) => static::getUrl('edit', ['record' => $record]))
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
@ -942,6 +1069,7 @@ public static function table(Table $table): Table
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
|
static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
|
||||||
|
static::makeRestoreTenantToWorkspaceAction(),
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
@ -999,6 +1127,7 @@ public static function table(Table $table): Table
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
static::makeRemoveTenantFromWorkspaceAction(),
|
||||||
static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow),
|
static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow),
|
||||||
])
|
])
|
||||||
->label('More')
|
->label('More')
|
||||||
@ -2352,9 +2481,19 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
||||||
|
Infolists\Components\TextEntry::make('workspace_posture')
|
||||||
|
->label('Workspace posture')
|
||||||
|
->badge()
|
||||||
|
->state(fn (ManagedEnvironment $record): string => app(WorkspaceLifecycleService::class)->tenantPosture($record))
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantWorkspacePosture)),
|
||||||
Infolists\Components\TextEntry::make('lifecycle_summary')
|
Infolists\Components\TextEntry::make('lifecycle_summary')
|
||||||
->label('Lifecycle summary')
|
->label('Lifecycle summary')
|
||||||
->state(fn (ManagedEnvironment $record): string => static::tenantLifecyclePresentation($record)->longDescription)
|
->state(fn (ManagedEnvironment $record): string => $record->isRemovedFromWorkspace()
|
||||||
|
? 'Removed from workspace. The tenant is hidden from active context and new tenant operations, while historical evidence and operations remain available.'
|
||||||
|
: static::tenantLifecyclePresentation($record)->longDescription)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@ -2762,7 +2901,9 @@ public static function tenantEditContextHtml(?ManagedEnvironment $tenant): HtmlS
|
|||||||
|
|
||||||
public static function tenantViewLifecycleGroupVisible(ManagedEnvironment $tenant): bool
|
public static function tenantViewLifecycleGroupVisible(ManagedEnvironment $tenant): bool
|
||||||
{
|
{
|
||||||
return in_array(static::lifecycleActionDescriptor($tenant, TenantActionSurface::TenantViewHeader)?->key, ['archive', 'restore'], true);
|
return in_array(static::lifecycleActionDescriptor($tenant, TenantActionSurface::TenantViewHeader)?->key, ['archive', 'restore'], true)
|
||||||
|
|| static::tenantWorkspaceRemovalActionVisible($tenant)
|
||||||
|
|| static::tenantWorkspaceRestoreActionVisible($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tenantViewExternalGroupVisible(ManagedEnvironment $tenant): bool
|
public static function tenantViewExternalGroupVisible(ManagedEnvironment $tenant): bool
|
||||||
@ -2772,7 +2913,24 @@ public static function tenantViewExternalGroupVisible(ManagedEnvironment $tenant
|
|||||||
|
|
||||||
public static function tenantViewSetupGroupVisible(ManagedEnvironment $tenant): bool
|
public static function tenantViewSetupGroupVisible(ManagedEnvironment $tenant): bool
|
||||||
{
|
{
|
||||||
return $tenant->isActive();
|
return static::tenantSetupMutationVisible($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantSetupMutationVisible(ManagedEnvironment $tenant): bool
|
||||||
|
{
|
||||||
|
return $tenant->isActive()
|
||||||
|
&& ! $tenant->isRemovedFromWorkspace()
|
||||||
|
&& ! $tenant->workspace?->isClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantWorkspaceRemovalActionVisible(ManagedEnvironment $tenant): bool
|
||||||
|
{
|
||||||
|
return ! $tenant->trashed() && ! $tenant->isRemovedFromWorkspace();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantWorkspaceRestoreActionVisible(ManagedEnvironment $tenant): bool
|
||||||
|
{
|
||||||
|
return $tenant->isRemovedFromWorkspace();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function verificationActionVisible(ManagedEnvironment $tenant): bool
|
public static function verificationActionVisible(ManagedEnvironment $tenant): bool
|
||||||
@ -2812,6 +2970,7 @@ private static function tenantActionCatalogCacheKey(ManagedEnvironment $tenant,
|
|||||||
$surface->value,
|
$surface->value,
|
||||||
(string) ($tenant->getKey() ?? 'missing'),
|
(string) ($tenant->getKey() ?? 'missing'),
|
||||||
(string) $tenant->status,
|
(string) $tenant->status,
|
||||||
|
(string) ($tenant->removed_from_workspace_at?->getTimestamp() ?? 'not-removed-from-workspace'),
|
||||||
(string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'),
|
(string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'),
|
||||||
(string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'),
|
(string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'),
|
||||||
(string) ($relatedDraft?->getKey() ?? 'no-draft'),
|
(string) ($relatedDraft?->getKey() ?? 'no-draft'),
|
||||||
@ -3059,7 +3218,7 @@ public static function rbacAction(): Actions\Action
|
|||||||
->noSearchResultsMessage('No security groups found')
|
->noSearchResultsMessage('No security groups found')
|
||||||
->loadingMessage('Searching groups...'),
|
->loadingMessage('Searching groups...'),
|
||||||
])
|
])
|
||||||
->visible(fn (ManagedEnvironment $record): bool => $record->isActive())
|
->visible(fn (ManagedEnvironment $record): bool => static::tenantSetupMutationVisible($record))
|
||||||
->disabled(function (ManagedEnvironment $record): bool {
|
->disabled(function (ManagedEnvironment $record): bool {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -3203,7 +3362,8 @@ public static function adminConsentUrl(ManagedEnvironment $tenant): ?string
|
|||||||
* consent_status:?string,
|
* consent_status:?string,
|
||||||
* verification_status:?string,
|
* verification_status:?string,
|
||||||
* last_health_check_at:?string,
|
* last_health_check_at:?string,
|
||||||
* last_error_reason_code:?string
|
* last_error_reason_code:?string,
|
||||||
|
* target_scope_summary:?string
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private static function providerConnectionState(ManagedEnvironment $tenant): array
|
private static function providerConnectionState(ManagedEnvironment $tenant): array
|
||||||
@ -3238,9 +3398,16 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
|
|||||||
'verification_status' => null,
|
'verification_status' => 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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$targetScopeSummary = ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$targetScopeSummary = 'Target scope needs review';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'state' => $connection->is_default ? 'default_configured' : 'configured',
|
'state' => $connection->is_default ? 'default_configured' : 'configured',
|
||||||
'cta_url' => $ctaUrl,
|
'cta_url' => $ctaUrl,
|
||||||
@ -3257,6 +3424,7 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
|
|||||||
: (is_string($connection->verification_status) ? $connection->verification_status : null),
|
: (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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ManagedEnvironment $record): bool => $record->isActive())
|
->visible(fn (ManagedEnvironment $record): bool => TenantResource::tenantSetupMutationVisible($record))
|
||||||
->action(function (ManagedEnvironment $record): void {
|
->action(function (ManagedEnvironment $record): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -152,6 +152,8 @@ protected function getHeaderActions(): array
|
|||||||
&& TenantResource::tenantViewTriageGroupVisible($this->getRecord())),
|
&& TenantResource::tenantViewTriageGroupVisible($this->getRecord())),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
TenantResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader),
|
TenantResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader),
|
||||||
|
TenantResource::makeRestoreTenantToWorkspaceAction(),
|
||||||
|
TenantResource::makeRemoveTenantFromWorkspaceAction(),
|
||||||
TenantResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader),
|
TenantResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader),
|
||||||
])
|
])
|
||||||
->label('Lifecycle')
|
->label('Lifecycle')
|
||||||
|
|||||||
@ -17,9 +17,11 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
WorkspaceUiEnforcement::forTableAction(
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->visible(fn (): bool => WorkspaceResource::canEdit($this->record)),
|
||||||
fn (): ?Workspace => $this->record,
|
fn (): ?Workspace => $this->record,
|
||||||
)
|
)
|
||||||
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::WORKSPACE_MANAGE)
|
->requireCapability(Capabilities::WORKSPACE_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -6,7 +6,10 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
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;
|
||||||
@ -18,6 +21,7 @@
|
|||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
|
use Filament\Infolists;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
@ -91,6 +95,7 @@ public static function canEdit(Model $record): bool
|
|||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
return $resolver->isMember($user, $record)
|
return $resolver->isMember($user, $record)
|
||||||
|
&& ! $record->isClosed()
|
||||||
&& $resolver->can($user, $record, Capabilities::WORKSPACE_MANAGE);
|
&& $resolver->can($user, $record, Capabilities::WORKSPACE_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,13 +166,26 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('slug')
|
Tables\Columns\TextColumn::make('slug')
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('closure_posture')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Workspace $record): string => app(WorkspaceLifecycleService::class)->workspacePosture($record))
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->description(fn (Workspace $record): ?string => $record->isClosed()
|
||||||
|
? 'Read-only; tenant context selection is disabled.'
|
||||||
|
: null),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
WorkspaceUiEnforcement::forTableAction(
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->visible(fn (Workspace $record): bool => static::canEdit($record)),
|
||||||
fn (): ?Workspace => null,
|
fn (): ?Workspace => null,
|
||||||
)
|
)
|
||||||
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::WORKSPACE_MANAGE)
|
->requireCapability(Capabilities::WORKSPACE_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
@ -182,6 +200,33 @@ public static function table(Table $table): Table
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Infolists\Components\TextEntry::make('name'),
|
||||||
|
Infolists\Components\TextEntry::make('slug'),
|
||||||
|
Infolists\Components\TextEntry::make('closure_posture')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Workspace $record): string => app(WorkspaceLifecycleService::class)->workspacePosture($record))
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::WorkspaceClosurePosture)),
|
||||||
|
Infolists\Components\TextEntry::make('closed_at')
|
||||||
|
->label('Closed at')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('Not closed'),
|
||||||
|
Infolists\Components\TextEntry::make('closure_reason')
|
||||||
|
->label('Closure reason')
|
||||||
|
->state(fn (Workspace $record): ?string => $record->closureReason())
|
||||||
|
->placeholder('Not closed')
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2);
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -94,6 +95,14 @@ public function table(Table $table): Table
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
||||||
|
TextColumn::make('workspace_posture')
|
||||||
|
->label('Workspace posture')
|
||||||
|
->state(fn (ManagedEnvironment $record): string => app(WorkspaceLifecycleService::class)->tenantPosture($record))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantWorkspacePosture))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantWorkspacePosture)),
|
||||||
TextColumn::make('health')
|
TextColumn::make('health')
|
||||||
->label('Health')
|
->label('Health')
|
||||||
->state(fn (ManagedEnvironment $record): string => $this->healthForTenant($record))
|
->state(fn (ManagedEnvironment $record): string => $this->healthForTenant($record))
|
||||||
@ -110,6 +119,10 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
private function healthForTenant(ManagedEnvironment $tenant): string
|
private function healthForTenant(ManagedEnvironment $tenant): string
|
||||||
{
|
{
|
||||||
|
if ($tenant->isRemovedFromWorkspace()) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
if ((string) $tenant->status === ManagedEnvironment::STATUS_ARCHIVED) {
|
if ((string) $tenant->status === ManagedEnvironment::STATUS_ARCHIVED) {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
|
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
|
||||||
use App\Services\Settings\SettingsWriter;
|
use App\Services\Settings\SettingsWriter;
|
||||||
|
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
@ -70,7 +71,7 @@ public function workspaceTenants(): Collection
|
|||||||
->where('workspace_id', (int) $this->workspace->getKey())
|
->where('workspace_id', (int) $this->workspace->getKey())
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get(['id', 'name', 'lifecycle_status', 'workspace_id', 'slug']);
|
->get(['id', 'name', 'lifecycle_status', 'workspace_id', 'slug', 'removed_from_workspace_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -123,6 +124,72 @@ public function workspaceCommercialLifecycleSummary(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Action::make('close_workspace')
|
||||||
|
->label('Close workspace')
|
||||||
|
->icon('heroicon-o-lock-closed')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (): bool => $this->canManageWorkspaceLifecycle() && ! $this->workspace->isClosed())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Close workspace')
|
||||||
|
->modalDescription('Closing a workspace removes it from active workspace selection and blocks new workspace and tenant mutations while preserving history.')
|
||||||
|
->form([
|
||||||
|
Textarea::make('reason')
|
||||||
|
->label('Closure reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(2000)
|
||||||
|
->rows(4),
|
||||||
|
])
|
||||||
|
->action(function (array $data, WorkspaceLifecycleService $service): void {
|
||||||
|
$actor = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $actor instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->workspace = $service
|
||||||
|
->closeWorkspace($this->workspace, $actor, (string) ($data['reason'] ?? ''))
|
||||||
|
->fresh()
|
||||||
|
->loadCount('tenants');
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Workspace closed')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Action::make('reopen_workspace')
|
||||||
|
->label('Reopen workspace')
|
||||||
|
->icon('heroicon-o-lock-open')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (): bool => $this->canManageWorkspaceLifecycle() && $this->workspace->isClosed())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Reopen workspace')
|
||||||
|
->modalDescription('Reopening makes the workspace eligible for normal workspace selection and tenant operations again, subject to RBAC and tenant posture.')
|
||||||
|
->form([
|
||||||
|
Textarea::make('reason')
|
||||||
|
->label('Reopen reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(2000)
|
||||||
|
->rows(4),
|
||||||
|
])
|
||||||
|
->action(function (array $data, WorkspaceLifecycleService $service): void {
|
||||||
|
$actor = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $actor instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->workspace = $service
|
||||||
|
->reopenWorkspace($this->workspace, $actor, (string) ($data['reason'] ?? ''))
|
||||||
|
->fresh()
|
||||||
|
->loadCount('tenants');
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Workspace reopened')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Action::make('request_support_access')
|
Action::make('request_support_access')
|
||||||
->label('Request support access')
|
->label('Request support access')
|
||||||
->icon('heroicon-o-lifebuoy')
|
->icon('heroicon-o-lifebuoy')
|
||||||
@ -397,6 +464,14 @@ private function canManageCommercialLifecycle(): bool
|
|||||||
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canManageWorkspaceLifecycle(): bool
|
||||||
|
{
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
return $user instanceof PlatformUser
|
||||||
|
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_MANAGE);
|
||||||
|
}
|
||||||
|
|
||||||
private function canManageSupportAccess(): bool
|
private function canManageSupportAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -81,6 +82,14 @@ public function table(Table $table): Table
|
|||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('tenants_count')
|
TextColumn::make('tenants_count')
|
||||||
->label('Tenants'),
|
->label('Tenants'),
|
||||||
|
TextColumn::make('closure_posture')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->state(fn (Workspace $record): string => app(WorkspaceLifecycleService::class)->workspacePosture($record))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::WorkspaceClosurePosture))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::WorkspaceClosurePosture)),
|
||||||
TextColumn::make('health')
|
TextColumn::make('health')
|
||||||
->label('Health')
|
->label('Health')
|
||||||
->state(fn (Workspace $record): string => $this->healthForWorkspace($record))
|
->state(fn (Workspace $record): string => $this->healthForWorkspace($record))
|
||||||
|
|||||||
@ -43,6 +43,7 @@ protected function getViewData(): array
|
|||||||
'recommendedActions' => [],
|
'recommendedActions' => [],
|
||||||
'governanceStatus' => [],
|
'governanceStatus' => [],
|
||||||
'readinessCards' => [],
|
'readinessCards' => [],
|
||||||
|
'activeOperationSummary' => null,
|
||||||
'recentOperations' => [],
|
'recentOperations' => [],
|
||||||
'pollingInterval' => null,
|
'pollingInterval' => null,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -77,7 +77,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
$workspace instanceof Workspace
|
$workspace instanceof Workspace
|
||||||
&& empty($workspace->archived_at)
|
&& $workspace->isSelectableAsContext()
|
||||||
&& $context->isMember($user, $workspace)
|
&& $context->isMember($user, $workspace)
|
||||||
) {
|
) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
@ -85,7 +85,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
$this->clearStaleSession($context, $user, $request, $workspace);
|
$this->clearStaleSession($context, $user, $request, $workspace);
|
||||||
|
|
||||||
if ($workspace instanceof Workspace && empty($workspace->archived_at)) {
|
if ($workspace instanceof Workspace && $workspace->isSelectableAsContext()) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,6 +97,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
->where('user_id', $user->getKey())
|
->where('user_id', $user->getKey())
|
||||||
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
||||||
->whereNull('workspaces.archived_at')
|
->whereNull('workspaces.archived_at')
|
||||||
|
->whereNull('workspaces.closed_at')
|
||||||
->select('workspace_memberships.*')
|
->select('workspace_memberships.*')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
@ -135,7 +136,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
$lastWorkspace instanceof Workspace
|
$lastWorkspace instanceof Workspace
|
||||||
&& empty($lastWorkspace->archived_at)
|
&& $lastWorkspace->isSelectableAsContext()
|
||||||
&& $context->isMember($user, $lastWorkspace)
|
&& $context->isMember($user, $lastWorkspace)
|
||||||
) {
|
) {
|
||||||
$context->setCurrentWorkspace($lastWorkspace, $user, $request);
|
$context->setCurrentWorkspace($lastWorkspace, $user, $request);
|
||||||
@ -160,8 +161,12 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
$user->forceFill(['last_workspace_id' => null])->save();
|
$user->forceFill(['last_workspace_id' => null])->save();
|
||||||
|
|
||||||
if ($workspaceName !== null) {
|
if ($workspaceName !== null) {
|
||||||
|
$message = $lastWorkspace?->isClosed()
|
||||||
|
? "The workspace {$workspaceName} was closed."
|
||||||
|
: "Your access to {$workspaceName} was removed.";
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title("Your access to {$workspaceName} was removed.")
|
->title($message)
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
@ -266,8 +271,12 @@ private function clearStaleSession(WorkspaceContext $context, User $user, Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($workspaceName !== null) {
|
if ($workspaceName !== null) {
|
||||||
|
$message = $workspace?->isClosed()
|
||||||
|
? "The workspace {$workspaceName} was closed."
|
||||||
|
: "Your access to {$workspaceName} was removed.";
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title("Your access to {$workspaceName} was removed.")
|
->title($message)
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -136,16 +137,19 @@ private function resolveEntraTenantName(ProviderConnection $connection, Provider
|
|||||||
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
||||||
{
|
{
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$targetScope = $context['target_scope'] ?? [];
|
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
|
||||||
|
|
||||||
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
|
|
||||||
|
|
||||||
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
||||||
$targetScope['entra_tenant_name'] = $entraTenantName;
|
$targetScope['scope_display_name'] = $entraTenantName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
|
||||||
$context['target_scope'] = $targetScope;
|
$context['target_scope'] = $targetScope;
|
||||||
|
$context['provider_context'] = $normalizer->providerContext(
|
||||||
|
provider: (string) $connection->provider,
|
||||||
|
details: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
|
);
|
||||||
|
|
||||||
$run->update(['context' => $context]);
|
$run->update(['context' => $context]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use App\Support\Verification\TenantPermissionCheckClusters;
|
use App\Support\Verification\TenantPermissionCheckClusters;
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@ -178,6 +179,9 @@ public function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$permissionChecks = TenantPermissionCheckClusters::buildChecks($tenant, $permissionRows, $inventory);
|
$permissionChecks = TenantPermissionCheckClusters::buildChecks($tenant, $permissionRows, $inventory);
|
||||||
|
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)
|
||||||
|
->descriptorForConnection($connection)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
$report = VerificationReportWriter::write(
|
$report = VerificationReportWriter::write(
|
||||||
run: $this->operationRun,
|
run: $this->operationRun,
|
||||||
@ -196,8 +200,8 @@ public function handle(
|
|||||||
'value' => (int) $connection->getKey(),
|
'value' => (int) $connection->getKey(),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'kind' => 'entra_tenant_id',
|
'kind' => 'target_scope_identifier',
|
||||||
'value' => (string) $connection->entra_tenant_id,
|
'value' => (string) ($targetScope['scope_identifier'] ?? $connection->entra_tenant_id),
|
||||||
],
|
],
|
||||||
is_numeric($result->meta['http_status'] ?? null) ? [
|
is_numeric($result->meta['http_status'] ?? null) ? [
|
||||||
'kind' => 'http_status',
|
'kind' => 'http_status',
|
||||||
@ -224,7 +228,7 @@ public function handle(
|
|||||||
],
|
],
|
||||||
identity: [
|
identity: [
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
'target_scope' => $targetScope,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -360,17 +364,20 @@ private function resolveEntraTenantName(ProviderConnection $connection, HealthRe
|
|||||||
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
||||||
{
|
{
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$targetScope = $context['target_scope'] ?? [];
|
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
|
||||||
|
|
||||||
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
|
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
|
||||||
$targetScope['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
|
|
||||||
|
|
||||||
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
||||||
$targetScope['entra_tenant_name'] = $entraTenantName;
|
$targetScope['scope_display_name'] = $entraTenantName;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context['target_scope'] = $targetScope;
|
$context['target_scope'] = $targetScope;
|
||||||
|
$context['provider_context'] = $normalizer->providerContext(
|
||||||
|
provider: (string) $connection->provider,
|
||||||
|
details: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
|
);
|
||||||
|
|
||||||
$run->update(['context' => $context]);
|
$run->update(['context' => $context]);
|
||||||
}
|
}
|
||||||
@ -453,9 +460,9 @@ private function logVerificationResult(
|
|||||||
'credential_source' => $identity->credentialSource,
|
'credential_source' => $identity->credentialSource,
|
||||||
'effective_client_id' => $identity->effectiveClientId,
|
'effective_client_id' => $identity->effectiveClientId,
|
||||||
'target_scope' => $identity->targetScope?->toArray(),
|
'target_scope' => $identity->targetScope?->toArray(),
|
||||||
'provider_identity_context' => array_map(
|
'provider_context' => array_map(
|
||||||
static fn ($detail): array => $detail->toArray(),
|
static fn ($detail): array => $detail->toArray(),
|
||||||
$identity->contextualIdentityDetails,
|
$identity->providerContextDetails,
|
||||||
),
|
),
|
||||||
'reason_code' => $reasonCode,
|
'reason_code' => $reasonCode,
|
||||||
'operation_run_id' => (int) $run->getKey(),
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -136,16 +137,19 @@ private function resolveEntraTenantName(ProviderConnection $connection, Provider
|
|||||||
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
||||||
{
|
{
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$targetScope = $context['target_scope'] ?? [];
|
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
|
||||||
|
|
||||||
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
|
|
||||||
|
|
||||||
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
||||||
$targetScope['entra_tenant_name'] = $entraTenantName;
|
$targetScope['scope_display_name'] = $entraTenantName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
|
||||||
$context['target_scope'] = $targetScope;
|
$context['target_scope'] = $targetScope;
|
||||||
|
$context['provider_context'] = $normalizer->providerContext(
|
||||||
|
provider: (string) $connection->provider,
|
||||||
|
details: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
|
);
|
||||||
|
|
||||||
$run->update(['context' => $context]);
|
$run->update(['context' => $context]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class ManagedEnvironment extends Model implements HasName
|
|||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
'is_current' => 'boolean',
|
'is_current' => 'boolean',
|
||||||
'rbac_last_checked_at' => 'datetime',
|
'rbac_last_checked_at' => 'datetime',
|
||||||
|
'removed_from_workspace_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getExternalIdAttribute(): ?string
|
public function getExternalIdAttribute(): ?string
|
||||||
@ -241,7 +242,11 @@ public static function activeQuery(): Builder
|
|||||||
{
|
{
|
||||||
return static::query()
|
return static::query()
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->where('lifecycle_status', TenantLifecycle::Active->value);
|
->whereNull('removed_from_workspace_at')
|
||||||
|
->where('lifecycle_status', TenantLifecycle::Active->value)
|
||||||
|
->whereHas('workspace', fn (Builder $query): Builder => $query
|
||||||
|
->whereNull('archived_at')
|
||||||
|
->whereNull('closed_at'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function skipTestWorkspaceProvisioning(bool $skip = true): void
|
public static function skipTestWorkspaceProvisioning(bool $skip = true): void
|
||||||
@ -336,6 +341,11 @@ public function workspace(): BelongsTo
|
|||||||
return $this->belongsTo(Workspace::class);
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function removedFromWorkspaceByUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'removed_from_workspace_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function roleMappings(): HasMany
|
public function roleMappings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TenantRoleMapping::class);
|
return $this->hasMany(TenantRoleMapping::class);
|
||||||
@ -552,11 +562,35 @@ public function isArchived(): bool
|
|||||||
|
|
||||||
public function isSelectableAsContext(): bool
|
public function isSelectableAsContext(): bool
|
||||||
{
|
{
|
||||||
return ! $this->trashed() && $this->lifecycle()->canSelectAsContext();
|
if ($this->trashed() || $this->isRemovedFromWorkspace() || ! $this->lifecycle()->canSelectAsContext()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->workspace_id === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = $this->relationLoaded('workspace')
|
||||||
|
? $this->workspace
|
||||||
|
: $this->workspace()->first(['id', 'archived_at', 'closed_at']);
|
||||||
|
|
||||||
|
return $workspace instanceof Workspace && $workspace->isSelectableAsContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canResumeOnboarding(): bool
|
public function canResumeOnboarding(): bool
|
||||||
{
|
{
|
||||||
return ! $this->trashed() && $this->lifecycle()->canResumeOnboarding();
|
return ! $this->trashed() && ! $this->isRemovedFromWorkspace() && $this->lifecycle()->canResumeOnboarding();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRemovedFromWorkspace(): bool
|
||||||
|
{
|
||||||
|
return $this->removed_from_workspace_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspaceRemovalReason(): ?string
|
||||||
|
{
|
||||||
|
$reason = trim((string) $this->removed_from_workspace_reason);
|
||||||
|
|
||||||
|
return $reason === '' ? null : $reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
@ -15,6 +16,25 @@ class Workspace extends Model
|
|||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'archived_at' => 'datetime',
|
||||||
|
'closed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<PlatformUser, $this>
|
||||||
|
*/
|
||||||
|
public function closedByPlatformUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlatformUser::class, 'closed_by_platform_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<WorkspaceMembership, $this>
|
* @return HasMany<WorkspaceMembership, $this>
|
||||||
*/
|
*/
|
||||||
@ -81,4 +101,26 @@ public function tenantSettings(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(TenantSetting::class);
|
return $this->hasMany(TenantSetting::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isClosed(): bool
|
||||||
|
{
|
||||||
|
return $this->closed_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isArchived(): bool
|
||||||
|
{
|
||||||
|
return $this->archived_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSelectableAsContext(): bool
|
||||||
|
{
|
||||||
|
return ! $this->isArchived() && ! $this->isClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closureReason(): ?string
|
||||||
|
{
|
||||||
|
$reason = trim((string) $this->closed_reason);
|
||||||
|
|
||||||
|
return $reason === '' ? null : $reason;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,6 +55,10 @@ final class WorkspaceCommercialLifecycleResolver
|
|||||||
|
|
||||||
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
|
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
|
||||||
|
|
||||||
|
public const REASON_FAMILY_WORKSPACE_CLOSURE = 'workspace_closure';
|
||||||
|
|
||||||
|
public const REASON_FAMILY_TENANT_WORKSPACE_REMOVAL = 'tenant_workspace_removal';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
||||||
private readonly WorkspaceSubscriptionResolver $workspaceSubscriptionResolver,
|
private readonly WorkspaceSubscriptionResolver $workspaceSubscriptionResolver,
|
||||||
@ -164,6 +168,9 @@ public function resolve(Workspace $workspace): array
|
|||||||
'subscription_key_date_label' => $subscriptionSummary['key_date_label'] ?? null,
|
'subscription_key_date_label' => $subscriptionSummary['key_date_label'] ?? null,
|
||||||
'subscription_key_date' => $subscriptionSummary['key_date'] ?? null,
|
'subscription_key_date' => $subscriptionSummary['key_date'] ?? null,
|
||||||
'subscription_needs_review' => (bool) ($subscriptionSummary['needs_review'] ?? false),
|
'subscription_needs_review' => (bool) ($subscriptionSummary['needs_review'] ?? false),
|
||||||
|
'workspace_closed' => $workspace->isClosed(),
|
||||||
|
'workspace_closed_at' => $workspace->closed_at,
|
||||||
|
'workspace_closure_reason' => $workspace->closureReason(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,6 +199,17 @@ public function reviewPackStartDecisionForTenant(ManagedEnvironment $tenant): ar
|
|||||||
{
|
{
|
||||||
$tenant->loadMissing('workspace');
|
$tenant->loadMissing('workspace');
|
||||||
|
|
||||||
|
if ($tenant->isRemovedFromWorkspace()) {
|
||||||
|
return $this->decision(
|
||||||
|
lifecycle: $this->resolve($tenant->workspace),
|
||||||
|
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||||
|
outcome: self::OUTCOME_BLOCK,
|
||||||
|
reasonFamily: self::REASON_FAMILY_TENANT_WORKSPACE_REMOVAL,
|
||||||
|
message: 'This tenant was removed from the workspace. New review-pack starts are blocked until the tenant is restored to the workspace.',
|
||||||
|
substrateDecision: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
|
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,6 +219,14 @@ public function reviewPackStartDecisionForTenant(ManagedEnvironment $tenant): ar
|
|||||||
*/
|
*/
|
||||||
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
|
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
|
||||||
{
|
{
|
||||||
|
if ($workspace->isClosed()) {
|
||||||
|
return $this->closedWorkspaceDecision(
|
||||||
|
$lifecycle,
|
||||||
|
self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||||
|
'This workspace is closed. New managed-tenant activation is blocked, while existing review, evidence, and operation history remains available under current RBAC.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
|
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
|
||||||
$workspace,
|
$workspace,
|
||||||
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
@ -251,6 +277,14 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
|
|||||||
*/
|
*/
|
||||||
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
|
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
|
||||||
{
|
{
|
||||||
|
if ($workspace->isClosed()) {
|
||||||
|
return $this->closedWorkspaceDecision(
|
||||||
|
$lifecycle,
|
||||||
|
self::ACTION_REVIEW_PACK_START,
|
||||||
|
'This workspace is closed. New review-pack starts are blocked, while existing review packs, evidence, and review history remain available under current RBAC.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
|
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
|
||||||
$workspace,
|
$workspace,
|
||||||
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||||
@ -301,6 +335,17 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle)
|
|||||||
*/
|
*/
|
||||||
private function readOnlyDecision(string $actionKey, array $lifecycle): array
|
private function readOnlyDecision(string $actionKey, array $lifecycle): array
|
||||||
{
|
{
|
||||||
|
if ((bool) ($lifecycle['workspace_closed'] ?? false)) {
|
||||||
|
return $this->decision(
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
actionKey: $actionKey,
|
||||||
|
outcome: self::OUTCOME_ALLOW_READ_ONLY,
|
||||||
|
reasonFamily: self::REASON_FAMILY_WORKSPACE_CLOSURE,
|
||||||
|
message: $this->lifecycleMessage($lifecycle, 'Closed workspaces keep existing review, evidence, generated-pack, and operation history available under current RBAC.'),
|
||||||
|
substrateDecision: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
|
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
|
||||||
return $this->decision(
|
return $this->decision(
|
||||||
lifecycle: $lifecycle,
|
lifecycle: $lifecycle,
|
||||||
@ -330,6 +375,22 @@ private function lifecycleMessage(array $lifecycle, string $message): string
|
|||||||
return sprintf('%s Commercial source: %s.', $message, $this->commercialSourceDescriptor($lifecycle));
|
return sprintf('%s Commercial source: %s.', $message, $this->commercialSourceDescriptor($lifecycle));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $lifecycle
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function closedWorkspaceDecision(array $lifecycle, string $actionKey, string $message): array
|
||||||
|
{
|
||||||
|
return $this->decision(
|
||||||
|
lifecycle: array_merge($lifecycle, ['workspace_closed' => true]),
|
||||||
|
actionKey: $actionKey,
|
||||||
|
outcome: self::OUTCOME_BLOCK,
|
||||||
|
reasonFamily: self::REASON_FAMILY_WORKSPACE_CLOSURE,
|
||||||
|
message: $this->lifecycleMessage($lifecycle, $message),
|
||||||
|
substrateDecision: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $lifecycle
|
* @param array<string, mixed> $lifecycle
|
||||||
*/
|
*/
|
||||||
@ -368,6 +429,9 @@ private function decision(
|
|||||||
'source' => (string) $lifecycle['source'],
|
'source' => (string) $lifecycle['source'],
|
||||||
'source_label' => (string) $lifecycle['source_label'],
|
'source_label' => (string) $lifecycle['source_label'],
|
||||||
'rationale' => $lifecycle['rationale'] ?? null,
|
'rationale' => $lifecycle['rationale'] ?? null,
|
||||||
|
'workspace_closed' => (bool) ($lifecycle['workspace_closed'] ?? false),
|
||||||
|
'workspace_closed_at' => $lifecycle['workspace_closed_at'] ?? null,
|
||||||
|
'workspace_closure_reason' => $lifecycle['workspace_closure_reason'] ?? null,
|
||||||
'entitlement_decision' => $substrateDecision,
|
'entitlement_decision' => $substrateDecision,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,6 +66,19 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
|
|||||||
return $run->created_at->lte(now()->subMinutes($thresholdMinutes));
|
return $run->created_at->lte(now()->subMinutes($thresholdMinutes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertTenantAllowsNewOperation(ManagedEnvironment $tenant): void
|
||||||
|
{
|
||||||
|
$tenant->loadMissing('workspace');
|
||||||
|
|
||||||
|
if ($tenant->isRemovedFromWorkspace()) {
|
||||||
|
throw new InvalidArgumentException('ManagedEnvironment was removed from the workspace; new operation runs are blocked until it is restored.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->workspace?->isClosed()) {
|
||||||
|
throw new InvalidArgumentException('Workspace is closed; new operation runs are blocked until it is reopened.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
||||||
{
|
{
|
||||||
return $this->forceFailNonTerminalRun(
|
return $this->forceFailNonTerminalRun(
|
||||||
@ -124,6 +137,8 @@ public function ensureRun(
|
|||||||
throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
|
throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertTenantAllowsNewOperation($tenant);
|
||||||
|
|
||||||
$hash = $this->calculateHash($tenant->id, $type, $inputs);
|
$hash = $this->calculateHash($tenant->id, $type, $inputs);
|
||||||
|
|
||||||
// Idempotency Check (Fast Path)
|
// Idempotency Check (Fast Path)
|
||||||
@ -194,6 +209,8 @@ public function ensureRunWithIdentity(
|
|||||||
throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
|
throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertTenantAllowsNewOperation($tenant);
|
||||||
|
|
||||||
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
|
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
|
||||||
|
|
||||||
// Idempotency Check (Fast Path)
|
// Idempotency Check (Fast Path)
|
||||||
@ -333,6 +350,8 @@ public function ensureRunWithIdentityStrict(
|
|||||||
throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
|
throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertTenantAllowsNewOperation($tenant);
|
||||||
|
|
||||||
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
|
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
|
||||||
|
|
||||||
$existing = OperationRun::query()
|
$existing = OperationRun::query()
|
||||||
|
|||||||
@ -25,7 +25,7 @@ public function make(ProviderConnection $connection, string $state): string
|
|||||||
throw new RuntimeException($resolution->message ?? 'Provider identity could not be resolved for admin consent.');
|
throw new RuntimeException($resolution->message ?? 'Provider identity could not be resolved for admin consent.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantSegment = trim($resolution->tenantContext) !== '' ? trim($resolution->tenantContext) : 'organizations';
|
$tenantSegment = $resolution->targetScopeIdentifier('organizations') ?? 'organizations';
|
||||||
|
|
||||||
return "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
return "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
||||||
'client_id' => $resolution->effectiveClientId,
|
'client_id' => $resolution->effectiveClientId,
|
||||||
|
|||||||
@ -44,10 +44,14 @@ public function getClientCredentials(ProviderConnection $connection): array
|
|||||||
throw new RuntimeException('Provider credential payload is missing required keys.');
|
throw new RuntimeException('Provider credential payload is missing required keys.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = $payload['managed_environment_id'] ?? null;
|
$targetScopeIdentifier = $payload['managed_environment_id'] ?? null;
|
||||||
|
|
||||||
if (is_string($tenantId) && $tenantId !== '' && $tenantId !== $connection->entra_tenant_id) {
|
if (
|
||||||
throw new InvalidArgumentException('Provider credential managed_environment_id does not match the connection entra_tenant_id.');
|
is_string($targetScopeIdentifier)
|
||||||
|
&& $targetScopeIdentifier !== ''
|
||||||
|
&& $targetScopeIdentifier !== $connection->entra_tenant_id
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Provider credential target scope does not match the connection target scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -10,70 +10,70 @@
|
|||||||
final class PlatformProviderIdentityResolver
|
final class PlatformProviderIdentityResolver
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
|
||||||
*/
|
*/
|
||||||
public function resolve(
|
public function resolve(
|
||||||
string $tenantContext,
|
string $targetScopeIdentifier,
|
||||||
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||||
array $contextualIdentityDetails = [],
|
array $providerContextDetails = [],
|
||||||
): ProviderIdentityResolution {
|
): ProviderIdentityResolution {
|
||||||
$targetTenant = trim($tenantContext);
|
$targetScopeIdentifier = trim($targetScopeIdentifier);
|
||||||
$clientId = trim((string) config('graph.client_id'));
|
$clientId = trim((string) config('graph.client_id'));
|
||||||
$clientSecret = trim((string) config('graph.client_secret'));
|
$clientSecret = trim((string) config('graph.client_secret'));
|
||||||
$authorityTenant = trim((string) config('graph.managed_environment_id', 'organizations'));
|
$authorityTenant = trim((string) config('graph.managed_environment_id', 'organizations'));
|
||||||
$redirectUri = trim((string) route('admin.consent.callback'));
|
$redirectUri = trim((string) route('admin.consent.callback'));
|
||||||
|
|
||||||
if ($targetTenant === '') {
|
if ($targetScopeIdentifier === '') {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
connectionType: ProviderConnectionType::Platform,
|
connectionType: ProviderConnectionType::Platform,
|
||||||
tenantContext: 'organizations',
|
|
||||||
credentialSource: 'platform_config',
|
credentialSource: 'platform_config',
|
||||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
message: 'Provider connection is missing target tenant scope.',
|
message: 'Provider connection is missing target tenant scope.',
|
||||||
targetScope: $targetScope,
|
targetScope: $targetScope,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$targetScope ??= ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||||
|
provider: 'microsoft',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: $targetScopeIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
if ($clientId === '') {
|
if ($clientId === '') {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
connectionType: ProviderConnectionType::Platform,
|
connectionType: ProviderConnectionType::Platform,
|
||||||
tenantContext: $targetTenant,
|
|
||||||
credentialSource: 'platform_config',
|
credentialSource: 'platform_config',
|
||||||
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
|
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
|
||||||
message: 'Platform app identity is not configured.',
|
message: 'Platform app identity is not configured.',
|
||||||
targetScope: $targetScope,
|
targetScope: $targetScope,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($clientSecret === '' || $redirectUri === '') {
|
if ($clientSecret === '' || $redirectUri === '') {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
connectionType: ProviderConnectionType::Platform,
|
connectionType: ProviderConnectionType::Platform,
|
||||||
tenantContext: $targetTenant,
|
|
||||||
credentialSource: 'platform_config',
|
credentialSource: 'platform_config',
|
||||||
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
|
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||||
message: 'Platform app identity is incomplete.',
|
message: 'Platform app identity is incomplete.',
|
||||||
targetScope: $targetScope,
|
targetScope: $targetScope,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderIdentityResolution::resolved(
|
return ProviderIdentityResolution::resolved(
|
||||||
connectionType: ProviderConnectionType::Platform,
|
connectionType: ProviderConnectionType::Platform,
|
||||||
tenantContext: $targetTenant,
|
targetScope: $targetScope,
|
||||||
effectiveClientId: $clientId,
|
effectiveClientId: $clientId,
|
||||||
credentialSource: 'platform_config',
|
credentialSource: 'platform_config',
|
||||||
clientSecret: $clientSecret,
|
clientSecret: $clientSecret,
|
||||||
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
|
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
|
||||||
redirectUri: $redirectUri,
|
redirectUri: $redirectUri,
|
||||||
targetScope: $targetScope,
|
providerContextDetails: array_values(array_merge($providerContextDetails, array_filter([
|
||||||
contextualIdentityDetails: $contextualIdentityDetails !== []
|
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
|
||||||
? array_values(array_merge($contextualIdentityDetails, array_filter([
|
ProviderIdentityContextMetadata::redirectUri($redirectUri),
|
||||||
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
|
]))),
|
||||||
ProviderIdentityContextMetadata::redirectUri($redirectUri),
|
|
||||||
])))
|
|
||||||
: [],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,7 +62,7 @@ public function graphOptions(ProviderConnection $connection, array $overrides =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return array_merge([
|
return array_merge([
|
||||||
'tenant' => $resolution->tenantContext,
|
'tenant' => $resolution->targetScopeIdentifier('organizations'),
|
||||||
'client_id' => $resolution->effectiveClientId,
|
'client_id' => $resolution->effectiveClientId,
|
||||||
'client_secret' => $resolution->clientSecret,
|
'client_secret' => $resolution->clientSecret,
|
||||||
'client_request_id' => (string) Str::uuid(),
|
'client_request_id' => (string) Str::uuid(),
|
||||||
|
|||||||
@ -10,12 +10,12 @@
|
|||||||
final class ProviderIdentityResolution
|
final class ProviderIdentityResolution
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
|
||||||
*/
|
*/
|
||||||
private function __construct(
|
private function __construct(
|
||||||
public readonly bool $resolved,
|
public readonly bool $resolved,
|
||||||
public readonly ProviderConnectionType $connectionType,
|
public readonly ProviderConnectionType $connectionType,
|
||||||
public readonly string $tenantContext,
|
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
|
||||||
public readonly ?string $effectiveClientId,
|
public readonly ?string $effectiveClientId,
|
||||||
public readonly string $credentialSource,
|
public readonly string $credentialSource,
|
||||||
public readonly ?string $clientSecret,
|
public readonly ?string $clientSecret,
|
||||||
@ -23,25 +23,26 @@ private function __construct(
|
|||||||
public readonly ?string $redirectUri,
|
public readonly ?string $redirectUri,
|
||||||
public readonly ?string $reasonCode,
|
public readonly ?string $reasonCode,
|
||||||
public readonly ?string $message,
|
public readonly ?string $message,
|
||||||
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
|
public readonly array $providerContextDetails,
|
||||||
public readonly array $contextualIdentityDetails,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
|
||||||
|
*/
|
||||||
public static function resolved(
|
public static function resolved(
|
||||||
ProviderConnectionType $connectionType,
|
ProviderConnectionType $connectionType,
|
||||||
string $tenantContext,
|
ProviderConnectionTargetScopeDescriptor $targetScope,
|
||||||
string $effectiveClientId,
|
string $effectiveClientId,
|
||||||
string $credentialSource,
|
string $credentialSource,
|
||||||
?string $clientSecret,
|
?string $clientSecret,
|
||||||
?string $authorityTenant,
|
?string $authorityTenant,
|
||||||
?string $redirectUri,
|
?string $redirectUri,
|
||||||
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
array $providerContextDetails = [],
|
||||||
array $contextualIdentityDetails = [],
|
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
resolved: true,
|
resolved: true,
|
||||||
connectionType: $connectionType,
|
connectionType: $connectionType,
|
||||||
tenantContext: $tenantContext,
|
targetScope: $targetScope,
|
||||||
effectiveClientId: $effectiveClientId,
|
effectiveClientId: $effectiveClientId,
|
||||||
credentialSource: $credentialSource,
|
credentialSource: $credentialSource,
|
||||||
clientSecret: $clientSecret,
|
clientSecret: $clientSecret,
|
||||||
@ -49,26 +50,25 @@ public static function resolved(
|
|||||||
redirectUri: $redirectUri,
|
redirectUri: $redirectUri,
|
||||||
reasonCode: null,
|
reasonCode: null,
|
||||||
message: null,
|
message: null,
|
||||||
targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext),
|
providerContextDetails: $providerContextDetails,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails !== []
|
|
||||||
? $contextualIdentityDetails
|
|
||||||
: self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
|
||||||
|
*/
|
||||||
public static function blocked(
|
public static function blocked(
|
||||||
ProviderConnectionType $connectionType,
|
ProviderConnectionType $connectionType,
|
||||||
string $tenantContext,
|
|
||||||
string $credentialSource,
|
string $credentialSource,
|
||||||
string $reasonCode,
|
string $reasonCode,
|
||||||
?string $message = null,
|
?string $message = null,
|
||||||
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||||
array $contextualIdentityDetails = [],
|
array $providerContextDetails = [],
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
resolved: false,
|
resolved: false,
|
||||||
connectionType: $connectionType,
|
connectionType: $connectionType,
|
||||||
tenantContext: $tenantContext,
|
targetScope: $targetScope,
|
||||||
effectiveClientId: null,
|
effectiveClientId: null,
|
||||||
credentialSource: $credentialSource,
|
credentialSource: $credentialSource,
|
||||||
clientSecret: null,
|
clientSecret: null,
|
||||||
@ -76,10 +76,7 @@ public static function blocked(
|
|||||||
redirectUri: null,
|
redirectUri: null,
|
||||||
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
||||||
message: $message,
|
message: $message,
|
||||||
targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null),
|
providerContextDetails: $providerContextDetails,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails !== []
|
|
||||||
? $contextualIdentityDetails
|
|
||||||
: self::contextualIdentityDetails($tenantContext),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,35 +85,51 @@ public function effectiveReasonCode(): string
|
|||||||
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
|
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function targetScopeFromContext(string $tenantContext): ProviderConnectionTargetScopeDescriptor
|
public function targetScopeIdentifier(?string $fallback = null): ?string
|
||||||
{
|
{
|
||||||
$identifier = trim($tenantContext) !== '' ? trim($tenantContext) : 'organizations';
|
$identifier = trim((string) $this->targetScope?->scopeIdentifier);
|
||||||
|
|
||||||
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
if ($identifier !== '') {
|
||||||
provider: 'microsoft',
|
return $identifier;
|
||||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
}
|
||||||
scopeIdentifier: $identifier,
|
|
||||||
scopeDisplayName: $identifier,
|
return $fallback;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<ProviderIdentityContextMetadata>
|
* @return array{client_id: ?string, credential_source: string}
|
||||||
*/
|
*/
|
||||||
private static function contextualIdentityDetails(
|
public function effectiveClientIdentity(): array
|
||||||
string $tenantContext,
|
{
|
||||||
?string $authorityTenant = null,
|
return [
|
||||||
?string $redirectUri = null,
|
'client_id' => $this->effectiveClientId,
|
||||||
): array {
|
'credential_source' => $this->credentialSource,
|
||||||
$details = [
|
|
||||||
ProviderIdentityContextMetadata::microsoftTenantId($tenantContext),
|
|
||||||
ProviderIdentityContextMetadata::authorityTenant($authorityTenant),
|
|
||||||
ProviderIdentityContextMetadata::redirectUri($redirectUri),
|
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return array_values(array_filter(
|
/**
|
||||||
$details,
|
* @return array{provider: string, details: list<array<string, string>>}
|
||||||
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
|
*/
|
||||||
));
|
public function providerContext(): array
|
||||||
|
{
|
||||||
|
$provider = $this->targetScope?->provider;
|
||||||
|
|
||||||
|
if (! is_string($provider) || trim($provider) === '') {
|
||||||
|
foreach ($this->providerContextDetails as $detail) {
|
||||||
|
if (trim($detail->provider) !== '') {
|
||||||
|
$provider = $detail->provider;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'provider' => is_string($provider) && trim($provider) !== '' ? trim($provider) : 'unknown',
|
||||||
|
'details' => array_map(
|
||||||
|
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||||
|
$this->providerContextDetails,
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,61 +22,58 @@ public function __construct(
|
|||||||
|
|
||||||
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
|
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
|
||||||
{
|
{
|
||||||
$tenantContext = trim((string) $connection->entra_tenant_id);
|
$targetScopeIdentifier = trim((string) $connection->entra_tenant_id);
|
||||||
$connectionType = $this->resolveConnectionType($connection);
|
$connectionType = $this->resolveConnectionType($connection);
|
||||||
$targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection);
|
$targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection);
|
||||||
$targetScope = $targetScopeResult['target_scope'] ?? null;
|
$targetScope = $targetScopeResult['target_scope'] ?? null;
|
||||||
$contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
|
$providerContextDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
|
||||||
|
|
||||||
if ($connectionType === null) {
|
if ($connectionType === null) {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
connectionType: ProviderConnectionType::Platform,
|
connectionType: ProviderConnectionType::Platform,
|
||||||
tenantContext: $tenantContext !== '' ? $tenantContext : 'organizations',
|
|
||||||
credentialSource: 'unknown',
|
credentialSource: 'unknown',
|
||||||
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
||||||
message: 'Provider connection type is invalid.',
|
message: 'Provider connection type is invalid.',
|
||||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
connectionType: $connectionType,
|
connectionType: $connectionType,
|
||||||
tenantContext: 'organizations',
|
|
||||||
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
|
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
|
||||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.',
|
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.',
|
||||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((bool) $connection->migration_review_required) {
|
if ((bool) $connection->migration_review_required) {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
connectionType: $connectionType,
|
connectionType: $connectionType,
|
||||||
tenantContext: $tenantContext,
|
|
||||||
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
|
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
|
||||||
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
|
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||||
message: 'Provider connection requires migration review before use.',
|
message: 'Provider connection requires migration review before use.',
|
||||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($connectionType === ProviderConnectionType::Platform) {
|
if ($connectionType === ProviderConnectionType::Platform) {
|
||||||
return $this->platformResolver->resolve(
|
return $this->platformResolver->resolve(
|
||||||
tenantContext: $tenantContext,
|
targetScopeIdentifier: $targetScopeIdentifier,
|
||||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveDedicatedIdentity(
|
return $this->resolveDedicatedIdentity(
|
||||||
connection: $connection,
|
connection: $connection,
|
||||||
tenantContext: $tenantContext,
|
targetScopeIdentifier: $targetScopeIdentifier,
|
||||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,36 +94,42 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide
|
|||||||
|
|
||||||
private function resolveDedicatedIdentity(
|
private function resolveDedicatedIdentity(
|
||||||
ProviderConnection $connection,
|
ProviderConnection $connection,
|
||||||
string $tenantContext,
|
string $targetScopeIdentifier,
|
||||||
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||||
array $contextualIdentityDetails = [],
|
array $providerContextDetails = [],
|
||||||
): ProviderIdentityResolution {
|
): ProviderIdentityResolution {
|
||||||
try {
|
try {
|
||||||
$credentials = $this->credentials->getClientCredentials($connection);
|
$credentials = $this->credentials->getClientCredentials($connection);
|
||||||
} catch (InvalidArgumentException|RuntimeException $exception) {
|
} catch (InvalidArgumentException|RuntimeException $exception) {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
connectionType: ProviderConnectionType::Dedicated,
|
connectionType: ProviderConnectionType::Dedicated,
|
||||||
tenantContext: $tenantContext,
|
|
||||||
credentialSource: $this->credentialSource($connection),
|
credentialSource: $this->credentialSource($connection),
|
||||||
reasonCode: $exception instanceof InvalidArgumentException
|
reasonCode: $exception instanceof InvalidArgumentException
|
||||||
? ProviderReasonCodes::DedicatedCredentialInvalid
|
? ProviderReasonCodes::DedicatedCredentialInvalid
|
||||||
: ProviderReasonCodes::DedicatedCredentialMissing,
|
: ProviderReasonCodes::DedicatedCredentialMissing,
|
||||||
message: $exception->getMessage(),
|
message: $exception->getMessage(),
|
||||||
targetScope: $targetScope,
|
targetScope: $targetScope,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
providerContextDetails: $providerContextDetails,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $targetScope instanceof ProviderConnectionTargetScopeDescriptor) {
|
||||||
|
$targetScope = ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||||
|
provider: 'microsoft',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: $targetScopeIdentifier !== '' ? $targetScopeIdentifier : 'organizations',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderIdentityResolution::resolved(
|
return ProviderIdentityResolution::resolved(
|
||||||
connectionType: ProviderConnectionType::Dedicated,
|
connectionType: ProviderConnectionType::Dedicated,
|
||||||
tenantContext: $tenantContext,
|
targetScope: $targetScope,
|
||||||
effectiveClientId: $credentials['client_id'],
|
effectiveClientId: $credentials['client_id'],
|
||||||
credentialSource: $this->credentialSource($connection),
|
credentialSource: $this->credentialSource($connection),
|
||||||
clientSecret: $credentials['client_secret'],
|
clientSecret: $credentials['client_secret'],
|
||||||
authorityTenant: $tenantContext,
|
authorityTenant: $targetScope->scopeIdentifier,
|
||||||
redirectUri: trim((string) route('admin.consent.callback')),
|
redirectUri: trim((string) route('admin.consent.callback')),
|
||||||
targetScope: $targetScope,
|
providerContextDetails: $providerContextDetails,
|
||||||
contextualIdentityDetails: $contextualIdentityDetails,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,9 @@
|
|||||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||||
use App\Support\Verification\BlockedVerificationReportFactory;
|
use App\Support\Verification\BlockedVerificationReportFactory;
|
||||||
use App\Support\Verification\StaleQueuedVerificationReportFactory;
|
use App\Support\Verification\StaleQueuedVerificationReportFactory;
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
@ -28,6 +31,7 @@ public function __construct(
|
|||||||
private readonly ProviderConnectionResolver $resolver,
|
private readonly ProviderConnectionResolver $resolver,
|
||||||
private readonly ProviderNextStepsRegistry $nextStepsRegistry,
|
private readonly ProviderNextStepsRegistry $nextStepsRegistry,
|
||||||
private readonly OperationRunCapabilityResolver $capabilityResolver,
|
private readonly OperationRunCapabilityResolver $capabilityResolver,
|
||||||
|
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -137,9 +141,9 @@ public function start(
|
|||||||
'module' => $definition['module'],
|
'module' => $definition['module'],
|
||||||
'provider_binding' => $this->bindingContext($binding),
|
'provider_binding' => $this->bindingContext($binding),
|
||||||
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
||||||
'target_scope' => [
|
'connection_type' => $lockedConnection->connection_type?->value ?? $lockedConnection->connection_type,
|
||||||
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
|
'target_scope' => $this->targetScopeContextForConnection($lockedConnection),
|
||||||
],
|
'provider_context' => $this->providerContextForConnection($lockedConnection),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$run = $this->runs->ensureRunWithIdentity(
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
@ -185,9 +189,9 @@ private function startBlocked(
|
|||||||
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
|
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
'module' => $module,
|
'module' => $module,
|
||||||
'target_scope' => [
|
'target_scope' => $connection instanceof ProviderConnection
|
||||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
? $this->targetScopeContextForConnection($connection)
|
||||||
],
|
: $this->targetScopeContextForTenant($tenant, $provider),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$identityInputs = [
|
$identityInputs = [
|
||||||
@ -202,6 +206,8 @@ private function startBlocked(
|
|||||||
|
|
||||||
if ($connection instanceof ProviderConnection) {
|
if ($connection instanceof ProviderConnection) {
|
||||||
$context['provider_connection_id'] = (int) $connection->getKey();
|
$context['provider_connection_id'] = (int) $connection->getKey();
|
||||||
|
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
|
||||||
|
$context['provider_context'] = $this->providerContextForConnection($connection);
|
||||||
$identityInputs['provider_connection_id'] = (int) $connection->getKey();
|
$identityInputs['provider_connection_id'] = (int) $connection->getKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,6 +293,71 @@ private function bindingContext(array $binding): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* provider: string,
|
||||||
|
* scope_kind: string,
|
||||||
|
* scope_identifier: string,
|
||||||
|
* scope_display_name: string,
|
||||||
|
* shared_label: string,
|
||||||
|
* shared_help_text: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function targetScopeContextForConnection(ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->targetScopeNormalizer->descriptorForConnection($connection)->toArray();
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
$identifier = trim((string) $connection->entra_tenant_id);
|
||||||
|
$fallbackIdentifier = $connection->tenant instanceof ManagedEnvironment
|
||||||
|
? trim((string) $connection->tenant->graphTenantId())
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||||
|
provider: (string) $connection->provider,
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: $identifier !== '' ? $identifier : ($fallbackIdentifier !== '' ? $fallbackIdentifier : (string) $connection->getKey()),
|
||||||
|
scopeDisplayName: (string) ($connection->tenant?->name ?? $connection->display_name ?? $identifier),
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* provider: string,
|
||||||
|
* scope_kind: string,
|
||||||
|
* scope_identifier: string,
|
||||||
|
* scope_display_name: string,
|
||||||
|
* shared_label: string,
|
||||||
|
* shared_help_text: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function targetScopeContextForTenant(ManagedEnvironment $tenant, string $provider): array
|
||||||
|
{
|
||||||
|
$identifier = trim($tenant->providerTenantContext());
|
||||||
|
|
||||||
|
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||||
|
provider: $provider !== '' ? $provider : 'unknown',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: $identifier,
|
||||||
|
scopeDisplayName: (string) ($tenant->name ?? $identifier),
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{provider: string, details: list<array<string, string>>}
|
||||||
|
*/
|
||||||
|
private function providerContextForConnection(ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'details' => array_map(
|
||||||
|
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||||
|
$this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $extraContext
|
* @param array<string, mixed> $extraContext
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -263,10 +263,7 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s
|
|||||||
public function reviewPackGenerationDecisionForTenant(ManagedEnvironment $tenant): array
|
public function reviewPackGenerationDecisionForTenant(ManagedEnvironment $tenant): array
|
||||||
{
|
{
|
||||||
$tenant->loadMissing('workspace');
|
$tenant->loadMissing('workspace');
|
||||||
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
|
$decision = $this->workspaceCommercialLifecycleResolver->reviewPackStartDecisionForTenant($tenant);
|
||||||
$tenant->workspace,
|
|
||||||
WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START,
|
|
||||||
);
|
|
||||||
|
|
||||||
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
|
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
|
||||||
? $decision['entitlement_decision']
|
? $decision['entitlement_decision']
|
||||||
|
|||||||
@ -112,6 +112,60 @@ public function evaluate(TenantOperabilityContext $context, TenantOperabilityQue
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$context->tenant->workspace?->isClosed()
|
||||||
|
&& in_array($question, [
|
||||||
|
TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
TenantOperabilityQuestion::RememberedContextValidity,
|
||||||
|
TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
TenantOperabilityQuestion::RestoreEligibility,
|
||||||
|
TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
||||||
|
TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||||
|
], true)
|
||||||
|
) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: $question,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::WorkspaceClosed,
|
||||||
|
discoverable: in_array($question, [
|
||||||
|
TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
TenantOperabilityQuestion::RestoreEligibility,
|
||||||
|
TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||||
|
], true),
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$context->tenant->isRemovedFromWorkspace()
|
||||||
|
&& in_array($question, [
|
||||||
|
TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
TenantOperabilityQuestion::RememberedContextValidity,
|
||||||
|
TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
||||||
|
TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||||
|
], true)
|
||||||
|
) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: $question,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::TenantRemovedFromWorkspace,
|
||||||
|
discoverable: in_array($question, [
|
||||||
|
TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||||
|
], true),
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return match ($question) {
|
return match ($question) {
|
||||||
TenantOperabilityQuestion::SelectorEligibility => $this->selectorEligibilityOutcome($context, $lifecycle),
|
TenantOperabilityQuestion::SelectorEligibility => $this->selectorEligibilityOutcome($context, $lifecycle),
|
||||||
TenantOperabilityQuestion::RememberedContextValidity => $this->rememberedContextOutcome($context, $lifecycle),
|
TenantOperabilityQuestion::RememberedContextValidity => $this->rememberedContextOutcome($context, $lifecycle),
|
||||||
@ -240,7 +294,11 @@ public function applySelectableScope(Builder $query, ?string $table = null): Bui
|
|||||||
|
|
||||||
return $query
|
return $query
|
||||||
->whereNull("{$prefix}deleted_at")
|
->whereNull("{$prefix}deleted_at")
|
||||||
->where("{$prefix}lifecycle_status", TenantLifecycle::Active->value);
|
->whereNull("{$prefix}removed_from_workspace_at")
|
||||||
|
->where("{$prefix}lifecycle_status", TenantLifecycle::Active->value)
|
||||||
|
->whereHas('workspace', fn (Builder $workspaceQuery): Builder => $workspaceQuery
|
||||||
|
->whereNull('archived_at')
|
||||||
|
->whereNull('closed_at'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applyAdministrativeDiscoverabilityScope(Builder $query, ?string $table = null): Builder
|
public function applyAdministrativeDiscoverabilityScope(Builder $query, ?string $table = null): Builder
|
||||||
|
|||||||
@ -120,9 +120,9 @@ public function providerConnectionCheckUsingConnection(
|
|||||||
'credential_source' => $identity->credentialSource,
|
'credential_source' => $identity->credentialSource,
|
||||||
'effective_client_id' => $identity->effectiveClientId,
|
'effective_client_id' => $identity->effectiveClientId,
|
||||||
'target_scope' => $identity->targetScope?->toArray(),
|
'target_scope' => $identity->targetScope?->toArray(),
|
||||||
'provider_identity_context' => array_map(
|
'provider_context' => array_map(
|
||||||
static fn ($detail): array => $detail->toArray(),
|
static fn ($detail): array => $detail->toArray(),
|
||||||
$identity->contextualIdentityDetails,
|
$identity->providerContextDetails,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -0,0 +1,284 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Workspaces;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class WorkspaceLifecycleService
|
||||||
|
{
|
||||||
|
public const WORKSPACE_POSTURE_OPEN = 'open';
|
||||||
|
|
||||||
|
public const WORKSPACE_POSTURE_CLOSED = 'closed';
|
||||||
|
|
||||||
|
public const TENANT_POSTURE_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const TENANT_POSTURE_REMOVED = 'removed_from_workspace';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly WorkspaceAuditLogger $auditLogger,
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function workspacePosture(Workspace $workspace): string
|
||||||
|
{
|
||||||
|
return $workspace->isClosed()
|
||||||
|
? self::WORKSPACE_POSTURE_CLOSED
|
||||||
|
: self::WORKSPACE_POSTURE_OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantPosture(ManagedEnvironment $tenant): string
|
||||||
|
{
|
||||||
|
return $tenant->isRemovedFromWorkspace()
|
||||||
|
? self::TENANT_POSTURE_REMOVED
|
||||||
|
: self::TENANT_POSTURE_ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assertWorkspaceMutationAllowed(Workspace $workspace): void
|
||||||
|
{
|
||||||
|
if (! $workspace->isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'workspace' => 'This workspace is closed. Reopen it before making workspace or tenant changes.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeWorkspace(Workspace $workspace, PlatformUser $actor, string $reason): Workspace
|
||||||
|
{
|
||||||
|
$this->authorizePlatformDirectoryManagement($actor);
|
||||||
|
$reason = $this->normalizeReason($reason);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($workspace, $actor, $reason): Workspace {
|
||||||
|
$workspace = Workspace::query()->lockForUpdate()->findOrFail($workspace->getKey());
|
||||||
|
|
||||||
|
if ($workspace->isClosed()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'reason' => 'This workspace is already closed.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace->forceFill([
|
||||||
|
'closed_at' => now(),
|
||||||
|
'closed_by_platform_user_id' => (int) $actor->getKey(),
|
||||||
|
'closed_reason' => $reason,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
ManagedEnvironment::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('is_current', true)
|
||||||
|
->update(['is_current' => false]);
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::WorkspaceClosed,
|
||||||
|
context: [
|
||||||
|
'reason' => $reason,
|
||||||
|
'before_status' => self::WORKSPACE_POSTURE_OPEN,
|
||||||
|
'after_status' => self::WORKSPACE_POSTURE_CLOSED,
|
||||||
|
'closed_at' => $workspace->closed_at?->toISOString(),
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
resourceType: 'workspace',
|
||||||
|
resourceId: (string) $workspace->getKey(),
|
||||||
|
targetLabel: (string) $workspace->name,
|
||||||
|
summary: 'Workspace closed for '.$workspace->name,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $workspace;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reopenWorkspace(Workspace $workspace, PlatformUser $actor, string $reason): Workspace
|
||||||
|
{
|
||||||
|
$this->authorizePlatformDirectoryManagement($actor);
|
||||||
|
$reason = $this->normalizeReason($reason);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($workspace, $actor, $reason): Workspace {
|
||||||
|
$workspace = Workspace::query()->lockForUpdate()->findOrFail($workspace->getKey());
|
||||||
|
|
||||||
|
if (! $workspace->isClosed()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'reason' => 'This workspace is already open.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousClosedAt = $workspace->closed_at;
|
||||||
|
$previousReason = $workspace->closureReason();
|
||||||
|
$previousActorId = $workspace->closed_by_platform_user_id;
|
||||||
|
|
||||||
|
$workspace->forceFill([
|
||||||
|
'closed_at' => null,
|
||||||
|
'closed_by_platform_user_id' => null,
|
||||||
|
'closed_reason' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::WorkspaceReopened,
|
||||||
|
context: [
|
||||||
|
'reason' => $reason,
|
||||||
|
'before_status' => self::WORKSPACE_POSTURE_CLOSED,
|
||||||
|
'after_status' => self::WORKSPACE_POSTURE_OPEN,
|
||||||
|
'previous_closed_at' => $previousClosedAt?->toISOString(),
|
||||||
|
'previous_closed_reason' => $previousReason,
|
||||||
|
'previous_closed_by_platform_user_id' => $previousActorId,
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
resourceType: 'workspace',
|
||||||
|
resourceId: (string) $workspace->getKey(),
|
||||||
|
targetLabel: (string) $workspace->name,
|
||||||
|
summary: 'Workspace reopened for '.$workspace->name,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $workspace;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTenantFromWorkspace(ManagedEnvironment $tenant, User $actor, string $reason): ManagedEnvironment
|
||||||
|
{
|
||||||
|
$reason = $this->normalizeReason($reason);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($tenant, $actor, $reason): ManagedEnvironment {
|
||||||
|
$tenant = ManagedEnvironment::query()
|
||||||
|
->with(['workspace'])
|
||||||
|
->withTrashed()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($tenant->getKey());
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::TENANT_DELETE)) {
|
||||||
|
throw new AuthorizationException('You are not allowed to remove this tenant from the workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->workspace?->isClosed()) {
|
||||||
|
$this->assertWorkspaceMutationAllowed($tenant->workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->isRemovedFromWorkspace()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'reason' => 'This tenant is already removed from the workspace.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'removed_from_workspace_at' => now(),
|
||||||
|
'removed_from_workspace_by_user_id' => (int) $actor->getKey(),
|
||||||
|
'removed_from_workspace_reason' => $reason,
|
||||||
|
'is_current' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->clearRememberedTenantContext();
|
||||||
|
|
||||||
|
$this->auditLogger->logTenantLifecycleAction(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: AuditActionId::TenantRemovedFromWorkspace,
|
||||||
|
context: [
|
||||||
|
'reason' => $reason,
|
||||||
|
'before_status' => self::TENANT_POSTURE_ACTIVE,
|
||||||
|
'after_status' => self::TENANT_POSTURE_REMOVED,
|
||||||
|
'removed_from_workspace_at' => $tenant->removed_from_workspace_at?->toISOString(),
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
summary: 'ManagedEnvironment removed from workspace for '.$tenant->name,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreTenantToWorkspace(ManagedEnvironment $tenant, User $actor, string $reason): ManagedEnvironment
|
||||||
|
{
|
||||||
|
$reason = $this->normalizeReason($reason);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($tenant, $actor, $reason): ManagedEnvironment {
|
||||||
|
$tenant = ManagedEnvironment::query()
|
||||||
|
->with(['workspace'])
|
||||||
|
->withTrashed()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($tenant->getKey());
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::TENANT_DELETE)) {
|
||||||
|
throw new AuthorizationException('You are not allowed to restore this tenant to the workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->workspace?->isClosed()) {
|
||||||
|
$this->assertWorkspaceMutationAllowed($tenant->workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant->isRemovedFromWorkspace()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'reason' => 'This tenant is not removed from the workspace.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousRemovedAt = $tenant->removed_from_workspace_at;
|
||||||
|
$previousReason = $tenant->workspaceRemovalReason();
|
||||||
|
$previousActorId = $tenant->removed_from_workspace_by_user_id;
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'removed_from_workspace_at' => null,
|
||||||
|
'removed_from_workspace_by_user_id' => null,
|
||||||
|
'removed_from_workspace_reason' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->auditLogger->logTenantLifecycleAction(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: AuditActionId::TenantRestoredToWorkspace,
|
||||||
|
context: [
|
||||||
|
'reason' => $reason,
|
||||||
|
'before_status' => self::TENANT_POSTURE_REMOVED,
|
||||||
|
'after_status' => self::TENANT_POSTURE_ACTIVE,
|
||||||
|
'previous_removed_from_workspace_at' => $previousRemovedAt?->toISOString(),
|
||||||
|
'previous_removed_from_workspace_reason' => $previousReason,
|
||||||
|
'previous_removed_from_workspace_by_user_id' => $previousActorId,
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
summary: 'ManagedEnvironment restored to workspace for '.$tenant->name,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePlatformDirectoryManagement(PlatformUser $actor): void
|
||||||
|
{
|
||||||
|
if ($actor->hasCapability(PlatformCapabilities::DIRECTORY_MANAGE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthorizationException('You are not allowed to manage workspace lifecycle.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeReason(string $reason): string
|
||||||
|
{
|
||||||
|
$reason = trim($reason);
|
||||||
|
|
||||||
|
if (mb_strlen($reason) < 5) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'reason' => 'Provide a reason with at least 5 characters.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($reason) > 2000) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'reason' => 'Provide a reason with 2000 characters or fewer.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,8 @@ enum AuditActionId: string
|
|||||||
case TenantArchived = 'tenant.archived';
|
case TenantArchived = 'tenant.archived';
|
||||||
case TenantRestored = 'tenant.restored';
|
case TenantRestored = 'tenant.restored';
|
||||||
case TenantReturnedToDraft = 'tenant.returned_to_draft';
|
case TenantReturnedToDraft = 'tenant.returned_to_draft';
|
||||||
|
case TenantRemovedFromWorkspace = 'tenant.removed_from_workspace';
|
||||||
|
case TenantRestoredToWorkspace = 'tenant.restored_to_workspace';
|
||||||
|
|
||||||
case TenantMembershipAdd = 'tenant_membership.add';
|
case TenantMembershipAdd = 'tenant_membership.add';
|
||||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
||||||
@ -62,6 +64,8 @@ enum AuditActionId: string
|
|||||||
case WorkspaceSettingUpdated = 'workspace_setting.updated';
|
case WorkspaceSettingUpdated = 'workspace_setting.updated';
|
||||||
case WorkspaceSettingReset = 'workspace_setting.reset';
|
case WorkspaceSettingReset = 'workspace_setting.reset';
|
||||||
case WorkspaceSubscriptionUpdated = 'workspace_subscription.updated';
|
case WorkspaceSubscriptionUpdated = 'workspace_subscription.updated';
|
||||||
|
case WorkspaceClosed = 'workspace.closed';
|
||||||
|
case WorkspaceReopened = 'workspace.reopened';
|
||||||
|
|
||||||
case BaselineProfileCreated = 'baseline_profile.created';
|
case BaselineProfileCreated = 'baseline_profile.created';
|
||||||
case BaselineProfileUpdated = 'baseline_profile.updated';
|
case BaselineProfileUpdated = 'baseline_profile.updated';
|
||||||
@ -193,6 +197,8 @@ private static function labels(): array
|
|||||||
self::TenantArchived->value => 'ManagedEnvironment archived',
|
self::TenantArchived->value => 'ManagedEnvironment archived',
|
||||||
self::TenantRestored->value => 'ManagedEnvironment restored',
|
self::TenantRestored->value => 'ManagedEnvironment restored',
|
||||||
self::TenantReturnedToDraft->value => 'ManagedEnvironment returned to draft',
|
self::TenantReturnedToDraft->value => 'ManagedEnvironment returned to draft',
|
||||||
|
self::TenantRemovedFromWorkspace->value => 'ManagedEnvironment removed from workspace',
|
||||||
|
self::TenantRestoredToWorkspace->value => 'ManagedEnvironment restored to workspace',
|
||||||
self::TenantMembershipAdd->value => 'ManagedEnvironment member add',
|
self::TenantMembershipAdd->value => 'ManagedEnvironment member add',
|
||||||
self::TenantMembershipRoleChange->value => 'ManagedEnvironment member role change',
|
self::TenantMembershipRoleChange->value => 'ManagedEnvironment member role change',
|
||||||
self::TenantMembershipRemove->value => 'ManagedEnvironment member removal',
|
self::TenantMembershipRemove->value => 'ManagedEnvironment member removal',
|
||||||
@ -227,6 +233,8 @@ private static function labels(): array
|
|||||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||||
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
|
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
|
||||||
|
self::WorkspaceClosed->value => 'Workspace closed',
|
||||||
|
self::WorkspaceReopened->value => 'Workspace reopened',
|
||||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||||
@ -333,6 +341,8 @@ private static function summaries(): array
|
|||||||
self::TenantArchived->value => 'ManagedEnvironment archived',
|
self::TenantArchived->value => 'ManagedEnvironment archived',
|
||||||
self::TenantRestored->value => 'ManagedEnvironment restored',
|
self::TenantRestored->value => 'ManagedEnvironment restored',
|
||||||
self::TenantReturnedToDraft->value => 'ManagedEnvironment returned to draft',
|
self::TenantReturnedToDraft->value => 'ManagedEnvironment returned to draft',
|
||||||
|
self::TenantRemovedFromWorkspace->value => 'ManagedEnvironment removed from workspace',
|
||||||
|
self::TenantRestoredToWorkspace->value => 'ManagedEnvironment restored to workspace',
|
||||||
self::TenantMembershipAdd->value => 'ManagedEnvironment member added',
|
self::TenantMembershipAdd->value => 'ManagedEnvironment member added',
|
||||||
self::TenantMembershipRoleChange->value => 'ManagedEnvironment member role changed',
|
self::TenantMembershipRoleChange->value => 'ManagedEnvironment member role changed',
|
||||||
self::TenantMembershipRemove->value => 'ManagedEnvironment member removed',
|
self::TenantMembershipRemove->value => 'ManagedEnvironment member removed',
|
||||||
@ -342,6 +352,8 @@ private static function summaries(): array
|
|||||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||||
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
|
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
|
||||||
|
self::WorkspaceClosed->value => 'Workspace closed',
|
||||||
|
self::WorkspaceReopened->value => 'Workspace reopened',
|
||||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||||
|
|||||||
@ -18,6 +18,8 @@ class PlatformCapabilities
|
|||||||
|
|
||||||
public const DIRECTORY_VIEW = 'platform.directory.view';
|
public const DIRECTORY_VIEW = 'platform.directory.view';
|
||||||
|
|
||||||
|
public const DIRECTORY_MANAGE = 'platform.directory.manage';
|
||||||
|
|
||||||
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
|
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
|
||||||
|
|
||||||
public const SUPPORT_ACCESS_MANAGE = 'platform.support_access.manage';
|
public const SUPPORT_ACCESS_MANAGE = 'platform.support_access.manage';
|
||||||
|
|||||||
@ -40,6 +40,7 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
|
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
|
||||||
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
|
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
|
||||||
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
|
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
|
||||||
|
BadgeDomain::TenantWorkspacePosture->value => Domains\TenantWorkspacePostureBadge::class,
|
||||||
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
|
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
|
||||||
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
|
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
|
||||||
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
||||||
@ -60,6 +61,7 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||||
|
BadgeDomain::WorkspaceClosurePosture->value => Domains\WorkspaceClosurePostureBadge::class,
|
||||||
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
|
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
|
||||||
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
|
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
|
||||||
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
||||||
|
|||||||
@ -31,6 +31,7 @@ enum BadgeDomain: string
|
|||||||
case BooleanEnabled = 'boolean_enabled';
|
case BooleanEnabled = 'boolean_enabled';
|
||||||
case BooleanHasErrors = 'boolean_has_errors';
|
case BooleanHasErrors = 'boolean_has_errors';
|
||||||
case TenantStatus = 'tenant_status';
|
case TenantStatus = 'tenant_status';
|
||||||
|
case TenantWorkspacePosture = 'tenant_workspace_posture';
|
||||||
case TenantRbacStatus = 'tenant_rbac_status';
|
case TenantRbacStatus = 'tenant_rbac_status';
|
||||||
case TenantPermissionStatus = 'tenant_permission_status';
|
case TenantPermissionStatus = 'tenant_permission_status';
|
||||||
case PolicySnapshotMode = 'policy_snapshot_mode';
|
case PolicySnapshotMode = 'policy_snapshot_mode';
|
||||||
@ -51,6 +52,7 @@ enum BadgeDomain: string
|
|||||||
case BaselineProfileStatus = 'baseline_profile_status';
|
case BaselineProfileStatus = 'baseline_profile_status';
|
||||||
case FindingType = 'finding_type';
|
case FindingType = 'finding_type';
|
||||||
case ReviewPackStatus = 'review_pack_status';
|
case ReviewPackStatus = 'review_pack_status';
|
||||||
|
case WorkspaceClosurePosture = 'workspace_closure_posture';
|
||||||
case CommercialLifecycleState = 'commercial_lifecycle_state';
|
case CommercialLifecycleState = 'commercial_lifecycle_state';
|
||||||
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
|
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
|
||||||
case EvidenceCompleteness = 'evidence_completeness';
|
case EvidenceCompleteness = 'evidence_completeness';
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class TenantWorkspacePostureBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
return match (BadgeCatalog::normalizeState($value)) {
|
||||||
|
WorkspaceLifecycleService::TENANT_POSTURE_ACTIVE => new BadgeSpec('In workspace', 'success', 'heroicon-m-check-circle'),
|
||||||
|
WorkspaceLifecycleService::TENANT_POSTURE_REMOVED => new BadgeSpec('Removed from workspace', 'warning', 'heroicon-m-no-symbol'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class WorkspaceClosurePostureBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
return match (BadgeCatalog::normalizeState($value)) {
|
||||||
|
WorkspaceLifecycleService::WORKSPACE_POSTURE_OPEN => new BadgeSpec('Open', 'success', 'heroicon-m-check-circle'),
|
||||||
|
WorkspaceLifecycleService::WORKSPACE_POSTURE_CLOSED => new BadgeSpec('Closed', 'danger', 'heroicon-m-lock-closed'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,16 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$path = '/'.ltrim($request->path(), '/');
|
||||||
|
|
||||||
|
if ($tenant->isRemovedFromWorkspace() && str_starts_with($path, '/admin/t/')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->workspace?->isClosed() && str_starts_with($path, '/admin/t/')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,12 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
if ($existingTenant instanceof ManagedEnvironment && $user instanceof User && ! $user->canAccessTenant($existingTenant)) {
|
if ($existingTenant instanceof ManagedEnvironment && $user instanceof User && ! $user->canAccessTenant($existingTenant)) {
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
|
$existingTenant = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingTenant instanceof ManagedEnvironment && ($existingTenant->isRemovedFromWorkspace() || $existingTenant->workspace?->isClosed())) {
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
$existingTenant = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isLivewireUpdatePath($path)) {
|
if ($this->isLivewireUpdatePath($path)) {
|
||||||
@ -103,6 +109,14 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->isRemovedFromWorkspace() && str_starts_with($path, '/admin/t/')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->workspace?->isClosed() && str_starts_with($path, '/admin/t/')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$resolvedContext->hasTenant()
|
$resolvedContext->hasTenant()
|
||||||
&& (
|
&& (
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public function __construct(
|
|||||||
public readonly string $verificationState,
|
public readonly string $verificationState,
|
||||||
public readonly string $readinessSummary,
|
public readonly string $readinessSummary,
|
||||||
public readonly array $contextualIdentityDetails = [],
|
public readonly array $contextualIdentityDetails = [],
|
||||||
|
public readonly bool $isEnabled = true,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function forConnection(ProviderConnection $connection): self
|
public static function forConnection(ProviderConnection $connection): self
|
||||||
@ -41,6 +42,7 @@ public static function forConnection(ProviderConnection $connection): self
|
|||||||
verificationState: $verificationState,
|
verificationState: $verificationState,
|
||||||
),
|
),
|
||||||
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
|
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
|
isEnabled: (bool) $connection->is_enabled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,7 +69,10 @@ public function contextualIdentityLine(): ?string
|
|||||||
* consent_state: string,
|
* consent_state: string,
|
||||||
* verification_state: string,
|
* verification_state: string,
|
||||||
* readiness_summary: string,
|
* readiness_summary: string,
|
||||||
* contextual_identity_details: list<array<string, string>>
|
* target_scope_summary: string,
|
||||||
|
* provider_context: array{provider: string, details: list<array<string, string>>},
|
||||||
|
* contextual_identity_line: ?string,
|
||||||
|
* is_enabled: bool
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
@ -78,7 +83,21 @@ public function toArray(): array
|
|||||||
'consent_state' => $this->consentState,
|
'consent_state' => $this->consentState,
|
||||||
'verification_state' => $this->verificationState,
|
'verification_state' => $this->verificationState,
|
||||||
'readiness_summary' => $this->readinessSummary,
|
'readiness_summary' => $this->readinessSummary,
|
||||||
'contextual_identity_details' => array_map(
|
'target_scope_summary' => $this->targetScopeSummary(),
|
||||||
|
'provider_context' => $this->providerContext(),
|
||||||
|
'contextual_identity_line' => $this->contextualIdentityLine(),
|
||||||
|
'is_enabled' => $this->isEnabled,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{provider: string, details: list<array<string, string>>}
|
||||||
|
*/
|
||||||
|
public function providerContext(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'details' => array_map(
|
||||||
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||||
$this->contextualIdentityDetails,
|
$this->contextualIdentityDetails,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -144,13 +144,31 @@ public function auditMetadataForConnection(ProviderConnection $connection, array
|
|||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'provider' => (string) $connection->provider,
|
'provider' => (string) $connection->provider,
|
||||||
'target_scope' => $summary->targetScope->toArray(),
|
'target_scope' => $summary->targetScope->toArray(),
|
||||||
'provider_identity_context' => array_map(
|
'provider_context' => [
|
||||||
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
'provider' => (string) $connection->provider,
|
||||||
$summary->contextualIdentityDetails,
|
'details' => array_map(
|
||||||
),
|
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||||
|
$summary->contextualIdentityDetails,
|
||||||
|
),
|
||||||
|
],
|
||||||
], $extra);
|
], $extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ProviderIdentityContextMetadata> $details
|
||||||
|
* @return array{provider: string, details: list<array<string, string>>}
|
||||||
|
*/
|
||||||
|
public function providerContext(string $provider, array $details): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => $provider,
|
||||||
|
'details' => array_map(
|
||||||
|
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||||
|
$details,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $fields
|
* @param list<string> $fields
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
* @param list<array<string, mixed>> $recommendedActions
|
* @param list<array<string, mixed>> $recommendedActions
|
||||||
* @param list<array<string, mixed>> $governanceStatus
|
* @param list<array<string, mixed>> $governanceStatus
|
||||||
* @param list<array<string, mixed>> $readinessCards
|
* @param list<array<string, mixed>> $readinessCards
|
||||||
|
* @param array<string, mixed>|null $activeOperationSummary
|
||||||
* @param list<array<string, mixed>> $recentOperations
|
* @param list<array<string, mixed>> $recentOperations
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@ -22,6 +23,7 @@ public function __construct(
|
|||||||
public array $recommendedActions,
|
public array $recommendedActions,
|
||||||
public array $governanceStatus,
|
public array $governanceStatus,
|
||||||
public array $readinessCards,
|
public array $readinessCards,
|
||||||
|
public ?array $activeOperationSummary,
|
||||||
public array $recentOperations,
|
public array $recentOperations,
|
||||||
public ?string $pollingInterval,
|
public ?string $pollingInterval,
|
||||||
) {}
|
) {}
|
||||||
@ -34,6 +36,7 @@ public function __construct(
|
|||||||
* recommendedActions: list<array<string, mixed>>,
|
* recommendedActions: list<array<string, mixed>>,
|
||||||
* governanceStatus: list<array<string, mixed>>,
|
* governanceStatus: list<array<string, mixed>>,
|
||||||
* readinessCards: list<array<string, mixed>>,
|
* readinessCards: list<array<string, mixed>>,
|
||||||
|
* activeOperationSummary: array<string, mixed>|null,
|
||||||
* recentOperations: list<array<string, mixed>>,
|
* recentOperations: list<array<string, mixed>>,
|
||||||
* pollingInterval: ?string,
|
* pollingInterval: ?string,
|
||||||
* }
|
* }
|
||||||
@ -47,6 +50,7 @@ public function toArray(): array
|
|||||||
'recommendedActions' => $this->recommendedActions,
|
'recommendedActions' => $this->recommendedActions,
|
||||||
'governanceStatus' => $this->governanceStatus,
|
'governanceStatus' => $this->governanceStatus,
|
||||||
'readinessCards' => $this->readinessCards,
|
'readinessCards' => $this->readinessCards,
|
||||||
|
'activeOperationSummary' => $this->activeOperationSummary,
|
||||||
'recentOperations' => $this->recentOperations,
|
'recentOperations' => $this->recentOperations,
|
||||||
'pollingInterval' => $this->pollingInterval,
|
'pollingInterval' => $this->pollingInterval,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -42,6 +43,7 @@
|
|||||||
use App\Support\Verification\VerificationReportOverall;
|
use App\Support\Verification\VerificationReportOverall;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
final class TenantDashboardSummaryBuilder
|
final class TenantDashboardSummaryBuilder
|
||||||
@ -142,6 +144,7 @@ public function build(ManagedEnvironment $tenant, ?User $user = null): TenantDas
|
|||||||
latestEvidenceSnapshot: $latestEvidenceSnapshot,
|
latestEvidenceSnapshot: $latestEvidenceSnapshot,
|
||||||
exceptionStats: $exceptionStats,
|
exceptionStats: $exceptionStats,
|
||||||
),
|
),
|
||||||
|
activeOperationSummary: $this->activeOperationSummary($tenant, $user),
|
||||||
recentOperations: $this->recentOperationCards($tenant, $recentOperations),
|
recentOperations: $this->recentOperationCards($tenant, $recentOperations),
|
||||||
pollingInterval: ActiveRuns::pollingIntervalForTenant($tenant),
|
pollingInterval: ActiveRuns::pollingIntervalForTenant($tenant),
|
||||||
);
|
);
|
||||||
@ -370,12 +373,7 @@ private function kpis(ManagedEnvironment $tenant, ?User $user, TenantGovernanceA
|
|||||||
$highSeverityChart = $this->highSeverityFindingsChart($tenant);
|
$highSeverityChart = $this->highSeverityFindingsChart($tenant);
|
||||||
$operationsFollowUpChart = $this->operationsFollowUpChart($tenant);
|
$operationsFollowUpChart = $this->operationsFollowUpChart($tenant);
|
||||||
|
|
||||||
$operationsNeedingFollowUp = (int) OperationRun::query()
|
$operationsNeedingFollowUp = (int) $this->operationsRequiringAttentionQuery($tenant)->count();
|
||||||
->where('managed_environment_id', (int) $tenant->getKey())
|
|
||||||
->where(function ($query): void {
|
|
||||||
$query->terminalFollowUp()->orWhere(fn ($inner) => $inner->activeStaleAttention());
|
|
||||||
})
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
$this->metricCard(
|
$this->metricCard(
|
||||||
@ -424,7 +422,7 @@ private function kpis(ManagedEnvironment $tenant, ?User $user, TenantGovernanceA
|
|||||||
action: $this->operationsAction(
|
action: $this->operationsAction(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
user: $user,
|
user: $user,
|
||||||
label: $this->overviewText('action_view_all_operations'),
|
label: $this->overviewText('action_open_operations_hub'),
|
||||||
activeTab: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : 'active',
|
activeTab: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : 'active',
|
||||||
problemClass: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : null,
|
problemClass: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : null,
|
||||||
),
|
),
|
||||||
@ -497,13 +495,8 @@ private function activeOperationsKpiDescription(int $count, ?array $chart): stri
|
|||||||
return $this->overviewText('kpi_active_operations_tendency_none');
|
return $this->overviewText('kpi_active_operations_tendency_none');
|
||||||
}
|
}
|
||||||
|
|
||||||
$windowCount = $chart === null ? 0 : array_sum($chart);
|
if ($count === 1) {
|
||||||
|
return $this->overviewText('kpi_active_operations_tendency_one');
|
||||||
if ($windowCount > 0) {
|
|
||||||
return $this->overviewText('kpi_active_operations_tendency_window', [
|
|
||||||
'count' => $count,
|
|
||||||
'window' => $windowCount,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->overviewText('kpi_active_operations_tendency', ['count' => $count]);
|
return $this->overviewText('kpi_active_operations_tendency', ['count' => $count]);
|
||||||
@ -548,9 +541,7 @@ private function highSeverityFindingsChart(ManagedEnvironment $tenant): ?array
|
|||||||
private function operationsFollowUpChart(ManagedEnvironment $tenant): ?array
|
private function operationsFollowUpChart(ManagedEnvironment $tenant): ?array
|
||||||
{
|
{
|
||||||
$window = $this->sevenDayWindow();
|
$window = $this->sevenDayWindow();
|
||||||
$byDay = OperationRun::query()
|
$byDay = $this->operationsRequiringAttentionQuery($tenant)
|
||||||
->where('managed_environment_id', (int) $tenant->getKey())
|
|
||||||
->dashboardNeedsFollowUp()
|
|
||||||
->where(function (Builder $query) use ($window): void {
|
->where(function (Builder $query) use ($window): void {
|
||||||
$query
|
$query
|
||||||
->whereBetween('completed_at', [$window['start'], $window['end']])
|
->whereBetween('completed_at', [$window['start'], $window['end']])
|
||||||
@ -628,21 +619,21 @@ private function recommendedActions(
|
|||||||
$candidates[] = $this->actionCandidate(
|
$candidates[] = $this->actionCandidate(
|
||||||
priority: 10,
|
priority: 10,
|
||||||
key: 'required_permissions',
|
key: 'required_permissions',
|
||||||
title: $this->overviewText('action_open_required_permissions'),
|
title: $this->overviewText('action_review_permissions'),
|
||||||
reason: $this->overviewText('reason_missing_application_permissions', ['count' => $missingApplicationPermissions]),
|
reason: $this->overviewText('reason_missing_application_permissions', ['count' => $missingApplicationPermissions]),
|
||||||
impact: $this->overviewText('impact_missing_application_permissions'),
|
impact: $this->overviewText('impact_missing_application_permissions'),
|
||||||
tone: 'danger',
|
tone: 'danger',
|
||||||
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_review_permissions')),
|
||||||
);
|
);
|
||||||
} elseif ($missingDelegatedPermissions > 0) {
|
} elseif ($missingDelegatedPermissions > 0) {
|
||||||
$candidates[] = $this->actionCandidate(
|
$candidates[] = $this->actionCandidate(
|
||||||
priority: 20,
|
priority: 20,
|
||||||
key: 'delegated_permissions',
|
key: 'delegated_permissions',
|
||||||
title: $this->overviewText('action_open_required_permissions'),
|
title: $this->overviewText('action_review_permissions'),
|
||||||
reason: $this->overviewText('reason_missing_delegated_permissions', ['count' => $missingDelegatedPermissions]),
|
reason: $this->overviewText('reason_missing_delegated_permissions', ['count' => $missingDelegatedPermissions]),
|
||||||
impact: $this->overviewText('impact_missing_delegated_permissions'),
|
impact: $this->overviewText('impact_missing_delegated_permissions'),
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_review_permissions')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -701,25 +692,24 @@ private function recommendedActions(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$terminalFollowUpRuns = (int) OperationRun::query()
|
$operationsRequiringAttention = $this->operationsRequiringAttentionRuns($tenant);
|
||||||
->where('managed_environment_id', (int) $tenant->getKey())
|
|
||||||
->terminalFollowUp()
|
if ($operationsRequiringAttention->isNotEmpty()) {
|
||||||
->count();
|
$dominantProblemClass = $this->dominantAttentionProblemClass($operationsRequiringAttention);
|
||||||
|
|
||||||
if ($terminalFollowUpRuns > 0) {
|
|
||||||
$candidates[] = $this->actionCandidate(
|
$candidates[] = $this->actionCandidate(
|
||||||
priority: 70,
|
priority: 35,
|
||||||
key: 'terminal_operations',
|
key: 'operations_requiring_attention',
|
||||||
title: $this->overviewText('action_view_all_operations'),
|
title: $this->overviewText('action_review_operations_requiring_attention'),
|
||||||
reason: $this->overviewText('reason_terminal_operations', ['count' => $terminalFollowUpRuns]),
|
reason: $this->overviewText('reason_operations_requiring_attention'),
|
||||||
impact: $this->overviewText('impact_terminal_operations'),
|
impact: $this->overviewText('impact_operations_requiring_attention'),
|
||||||
tone: 'danger',
|
tone: 'danger',
|
||||||
action: $this->operationsAction(
|
action: $this->operationsAction(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
user: $user,
|
user: $user,
|
||||||
label: $this->overviewText('action_view_all_operations'),
|
label: $this->overviewText('action_review_operations'),
|
||||||
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
activeTab: $dominantProblemClass,
|
||||||
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
problemClass: $dominantProblemClass,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -993,6 +983,68 @@ private function recentOperationCards(ManagedEnvironment $tenant, array $recentO
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function activeOperationSummary(ManagedEnvironment $tenant, ?User $user): ?array
|
||||||
|
{
|
||||||
|
if (! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qualifyingRuns = $this->operationsRequiringAttentionRuns($tenant);
|
||||||
|
|
||||||
|
if ($qualifyingRuns->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dominantProblemClass = $this->dominantAttentionProblemClass($qualifyingRuns);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $this->overviewText('operations_attention_title'),
|
||||||
|
'count' => $qualifyingRuns->count(),
|
||||||
|
'tone' => 'warning',
|
||||||
|
'secondaryActionLabel' => $this->overviewText('action_open_operations_hub'),
|
||||||
|
'secondaryActionUrl' => OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: $dominantProblemClass,
|
||||||
|
problemClass: $dominantProblemClass,
|
||||||
|
),
|
||||||
|
'items' => $this->attentionOperationItems($qualifyingRuns, $tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareActiveOperationSummaryRuns(OperationRun $left, OperationRun $right): int
|
||||||
|
{
|
||||||
|
$priorityComparison = $this->activeOperationSummaryPriority($left) <=> $this->activeOperationSummaryPriority($right);
|
||||||
|
|
||||||
|
if ($priorityComparison !== 0) {
|
||||||
|
return $priorityComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestampComparison = $this->activeOperationSummaryTimestamp($right) <=> $this->activeOperationSummaryTimestamp($left);
|
||||||
|
|
||||||
|
if ($timestampComparison !== 0) {
|
||||||
|
return $timestampComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int) $right->getKey()) <=> ((int) $left->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeOperationSummaryPriority(OperationRun $run): int
|
||||||
|
{
|
||||||
|
return match ($run->problemClass()) {
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 0,
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 1,
|
||||||
|
default => 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeOperationSummaryTimestamp(OperationRun $run): int
|
||||||
|
{
|
||||||
|
return ($run->completed_at ?? $run->started_at ?? $run->created_at)?->getTimestamp() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function governanceStatusIcon(string $key): string
|
private function governanceStatusIcon(string $key): string
|
||||||
{
|
{
|
||||||
return match ($key) {
|
return match ($key) {
|
||||||
@ -1067,7 +1119,7 @@ private function recommendedActionIcon(string $key): string
|
|||||||
return match ($key) {
|
return match ($key) {
|
||||||
'required_permissions', 'delegated_permissions', 'high_severity_findings' => 'heroicon-m-shield-exclamation',
|
'required_permissions', 'delegated_permissions', 'high_severity_findings' => 'heroicon-m-shield-exclamation',
|
||||||
'overdue_findings' => 'heroicon-o-clock',
|
'overdue_findings' => 'heroicon-o-clock',
|
||||||
'recovery_posture', 'terminal_operations', 'continue_review' => 'heroicon-o-arrow-path-rounded-square',
|
'recovery_posture', 'operations_requiring_attention', 'continue_review' => 'heroicon-o-arrow-path-rounded-square',
|
||||||
'risk_exceptions' => 'heroicon-o-exclamation-triangle',
|
'risk_exceptions' => 'heroicon-o-exclamation-triangle',
|
||||||
default => 'heroicon-o-exclamation-triangle',
|
default => 'heroicon-o-exclamation-triangle',
|
||||||
};
|
};
|
||||||
@ -1220,6 +1272,170 @@ private function operationsAction(ManagedEnvironment $tenant, ?User $user, strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function operationsRequiringAttentionQuery(ManagedEnvironment $tenant): Builder
|
||||||
|
{
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->dashboardNeedsFollowUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, OperationRun>
|
||||||
|
*/
|
||||||
|
private function operationsRequiringAttentionRuns(ManagedEnvironment $tenant): Collection
|
||||||
|
{
|
||||||
|
return $this->operationsRequiringAttentionQuery($tenant)
|
||||||
|
->get()
|
||||||
|
->sort(fn (OperationRun $left, OperationRun $right): int => $this->compareActiveOperationSummaryRuns($left, $right))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, OperationRun> $runs
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function attentionOperationItems(Collection $runs, ManagedEnvironment $tenant): array
|
||||||
|
{
|
||||||
|
return $runs
|
||||||
|
->take(3)
|
||||||
|
->map(function (OperationRun $run) use ($tenant): array {
|
||||||
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'freshness_state' => $run->freshnessState()->value,
|
||||||
|
]);
|
||||||
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $run->outcome,
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'freshness_state' => $run->freshnessState()->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $run->getKey(),
|
||||||
|
'identifier' => OperationRunLinks::identifier($run),
|
||||||
|
'type' => OperationCatalog::label((string) $run->type),
|
||||||
|
'title' => $this->attentionOperationTitle($run),
|
||||||
|
'icon' => $this->recentOperationIcon((string) $run->type),
|
||||||
|
'attentionLabel' => $this->attentionOperationBadgeLabel($run),
|
||||||
|
'problemClass' => $run->problemClass(),
|
||||||
|
'problemClassLabel' => OperationUxPresenter::problemClassLabel($run),
|
||||||
|
'statusLabel' => $statusSpec->label,
|
||||||
|
'statusTone' => $statusSpec->color,
|
||||||
|
'outcomeLabel' => $outcomeSpec->label,
|
||||||
|
'outcomeTone' => $outcomeSpec->color,
|
||||||
|
'outcomeSentence' => $this->attentionOperationOutcomeSentence($run),
|
||||||
|
'reason' => $this->attentionOperationReason($run),
|
||||||
|
'impact' => $this->attentionOperationImpact($run),
|
||||||
|
'timingLabel' => $this->attentionOperationTimingLabel($run),
|
||||||
|
'createdAt' => $run->completed_at?->diffForHumans() ?? $run->created_at?->diffForHumans(),
|
||||||
|
'primaryActionLabel' => $this->overviewText('action_review_operation'),
|
||||||
|
'primaryActionUrl' => OperationRunLinks::view($run, $tenant),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, OperationRun> $runs
|
||||||
|
*/
|
||||||
|
private function dominantAttentionProblemClass(Collection $runs): string
|
||||||
|
{
|
||||||
|
return $runs->contains(fn (OperationRun $run): bool => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||||
|
? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||||
|
: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attentionOperationTitle(OperationRun $run): string
|
||||||
|
{
|
||||||
|
return OperationCatalog::label((string) $run->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attentionOperationBadgeLabel(OperationRun $run): string
|
||||||
|
{
|
||||||
|
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
|
||||||
|
? $this->overviewText('operations_attention_badge_stale')
|
||||||
|
: $this->overviewText('operations_attention_badge_follow_up');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attentionOperationOutcomeSentence(OperationRun $run): string
|
||||||
|
{
|
||||||
|
if ($run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION) {
|
||||||
|
return $this->overviewText('operations_attention_outcome_stale');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isProviderConsentBlockedRun($run)) {
|
||||||
|
return $this->overviewText('operations_attention_outcome_provider_consent_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ((string) $run->outcome) {
|
||||||
|
OperationRunOutcome::Blocked->value => $this->overviewText('operations_attention_outcome_blocked'),
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value => $this->overviewText('operations_attention_outcome_partial'),
|
||||||
|
OperationRunOutcome::Failed->value => $this->overviewText('operations_attention_outcome_failed'),
|
||||||
|
default => $this->overviewText('operations_attention_outcome_generic'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attentionOperationReason(OperationRun $run): string
|
||||||
|
{
|
||||||
|
if ($this->isProviderConsentBlockedRun($run)) {
|
||||||
|
return $this->overviewText('operations_attention_reason_provider_consent_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$operatorExplanation = OperationUxPresenter::governanceOperatorExplanation($run);
|
||||||
|
$reason = trim((string) ($operatorExplanation?->dominantCauseExplanation ?? ''));
|
||||||
|
|
||||||
|
if ($reason !== '') {
|
||||||
|
return $reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
$failureDetail = trim((string) (OperationUxPresenter::surfaceFailureDetail($run) ?? ''));
|
||||||
|
|
||||||
|
if ($failureDetail !== '') {
|
||||||
|
return $failureDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
|
||||||
|
? $this->overviewText('operations_attention_reason_stale')
|
||||||
|
: $this->overviewText('operations_attention_reason_fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attentionOperationImpact(OperationRun $run): string
|
||||||
|
{
|
||||||
|
if ($this->isProviderConsentBlockedRun($run)) {
|
||||||
|
return $this->overviewText('operations_attention_impact_provider_consent_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
|
||||||
|
? $this->overviewText('operations_attention_impact_stale')
|
||||||
|
: $this->overviewText('operations_attention_impact_follow_up');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attentionOperationTimingLabel(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
if ($run->completed_at instanceof Carbon) {
|
||||||
|
return $this->overviewText('operations_attention_timing_completed', [
|
||||||
|
'time' => $run->completed_at->diffForHumans(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reference = $run->started_at ?? $run->created_at;
|
||||||
|
|
||||||
|
if (! $reference instanceof Carbon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->overviewText('operations_attention_timing_started', [
|
||||||
|
'time' => $reference->diffForHumans(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isProviderConsentBlockedRun(OperationRun $run): bool
|
||||||
|
{
|
||||||
|
return OperationCatalog::canonicalCode((string) $run->type) === OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK
|
||||||
|
&& (string) $run->outcome === OperationRunOutcome::Blocked->value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -13,6 +13,8 @@ public function __construct(
|
|||||||
public ?int $tenantId,
|
public ?int $tenantId,
|
||||||
public ?string $tenantName,
|
public ?string $tenantName,
|
||||||
public TenantLifecyclePresentation $presentation,
|
public TenantLifecyclePresentation $presentation,
|
||||||
|
public bool $removedFromWorkspace,
|
||||||
|
public bool $workspaceClosed,
|
||||||
public ?string $contextNote,
|
public ?string $contextNote,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -30,7 +32,9 @@ public static function fromTenant(ManagedEnvironment $tenant, string $viewerCont
|
|||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
tenantName: $tenant->name,
|
tenantName: $tenant->name,
|
||||||
presentation: $presentation,
|
presentation: $presentation,
|
||||||
contextNote: self::contextNoteFor($presentation),
|
removedFromWorkspace: $tenant->isRemovedFromWorkspace(),
|
||||||
|
workspaceClosed: (bool) $tenant->workspace?->isClosed(),
|
||||||
|
contextNote: self::contextNoteFor($presentation, $tenant->isRemovedFromWorkspace(), (bool) $tenant->workspace?->isClosed()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,12 +45,22 @@ public static function forInvalid(string $viewerContext, ?ManagedEnvironment $te
|
|||||||
tenantId: $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null,
|
tenantId: $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null,
|
||||||
tenantName: $tenant?->name,
|
tenantName: $tenant?->name,
|
||||||
presentation: TenantLifecyclePresentation::invalid($normalizedValue),
|
presentation: TenantLifecyclePresentation::invalid($normalizedValue),
|
||||||
|
removedFromWorkspace: $tenant?->isRemovedFromWorkspace() ?? false,
|
||||||
|
workspaceClosed: (bool) $tenant?->workspace?->isClosed(),
|
||||||
contextNote: 'Some tenant follow-up actions may be unavailable from this canonical workspace view.',
|
contextNote: 'Some tenant follow-up actions may be unavailable from this canonical workspace view.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function selectorAvailabilityMessage(): ?string
|
public function selectorAvailabilityMessage(): ?string
|
||||||
{
|
{
|
||||||
|
if ($this->removedFromWorkspace) {
|
||||||
|
return 'This tenant was removed from its workspace and may not appear in the tenant selector.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->workspaceClosed) {
|
||||||
|
return 'This tenant belongs to a closed workspace and may not appear in active tenant context.';
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->presentation->isInvalidFallback) {
|
if ($this->presentation->isInvalidFallback) {
|
||||||
return 'This tenant has an invalid lifecycle value and may not appear in the tenant selector.';
|
return 'This tenant has an invalid lifecycle value and may not appear in the tenant selector.';
|
||||||
}
|
}
|
||||||
@ -58,8 +72,12 @@ public function selectorAvailabilityMessage(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function contextNoteFor(TenantLifecyclePresentation $presentation): ?string
|
private static function contextNoteFor(TenantLifecyclePresentation $presentation, bool $removedFromWorkspace, bool $workspaceClosed): ?string
|
||||||
{
|
{
|
||||||
|
if ($removedFromWorkspace || $workspaceClosed) {
|
||||||
|
return 'Historical operation context remains available, but active tenant follow-up actions are unavailable from this canonical workspace view.';
|
||||||
|
}
|
||||||
|
|
||||||
if ($presentation->isInvalidFallback || ! $presentation->isSelectableAsContext()) {
|
if ($presentation->isInvalidFallback || ! $presentation->isSelectableAsContext()) {
|
||||||
return 'Some tenant follow-up actions may be unavailable from this canonical workspace view.';
|
return 'Some tenant follow-up actions may be unavailable from this canonical workspace view.';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,8 @@ enum TenantOperabilityReasonCode: string
|
|||||||
case OnboardingNotResumable = 'onboarding_not_resumable';
|
case OnboardingNotResumable = 'onboarding_not_resumable';
|
||||||
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
|
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
|
||||||
case RememberedContextStale = 'remembered_context_stale';
|
case RememberedContextStale = 'remembered_context_stale';
|
||||||
|
case WorkspaceClosed = 'workspace_closed';
|
||||||
|
case TenantRemovedFromWorkspace = 'tenant_removed_from_workspace';
|
||||||
|
|
||||||
public function operatorLabel(): string
|
public function operatorLabel(): string
|
||||||
{
|
{
|
||||||
@ -36,6 +38,8 @@ public function operatorLabel(): string
|
|||||||
self::OnboardingNotResumable => 'Onboarding cannot be resumed',
|
self::OnboardingNotResumable => 'Onboarding cannot be resumed',
|
||||||
self::CanonicalViewFollowupOnly => 'Follow-up requires tenant context',
|
self::CanonicalViewFollowupOnly => 'Follow-up requires tenant context',
|
||||||
self::RememberedContextStale => 'Saved tenant context is stale',
|
self::RememberedContextStale => 'Saved tenant context is stale',
|
||||||
|
self::WorkspaceClosed => 'Workspace is closed',
|
||||||
|
self::TenantRemovedFromWorkspace => 'ManagedEnvironment removed from workspace',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +56,8 @@ public function shortExplanation(): string
|
|||||||
self::OnboardingNotResumable => 'This onboarding session can no longer be resumed from the current lifecycle state.',
|
self::OnboardingNotResumable => 'This onboarding session can no longer be resumed from the current lifecycle state.',
|
||||||
self::CanonicalViewFollowupOnly => 'This canonical workspace view is informational only and cannot complete tenant follow-up directly.',
|
self::CanonicalViewFollowupOnly => 'This canonical workspace view is informational only and cannot complete tenant follow-up directly.',
|
||||||
self::RememberedContextStale => 'The remembered tenant context is no longer valid for the current tenant selector state.',
|
self::RememberedContextStale => 'The remembered tenant context is no longer valid for the current tenant selector state.',
|
||||||
|
self::WorkspaceClosed => 'This workspace is closed and cannot be used for active tenant context or new tenant operations until it is reopened.',
|
||||||
|
self::TenantRemovedFromWorkspace => 'This tenant was removed from the workspace and cannot be selected or used for new tenant operations until it is restored.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +65,7 @@ public function actionability(): string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::TenantAlreadyArchived => 'non_actionable',
|
self::TenantAlreadyArchived => 'non_actionable',
|
||||||
self::SelectorIneligibleLifecycle, self::TenantNotArchived, self::OnboardingNotResumable, self::CanonicalViewFollowupOnly, self::RememberedContextStale => 'prerequisite_missing',
|
self::SelectorIneligibleLifecycle, self::TenantNotArchived, self::OnboardingNotResumable, self::CanonicalViewFollowupOnly, self::RememberedContextStale, self::WorkspaceClosed, self::TenantRemovedFromWorkspace => 'prerequisite_missing',
|
||||||
default => 'permanent_configuration',
|
default => 'permanent_configuration',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -103,6 +109,12 @@ public function nextSteps(): array
|
|||||||
self::SelectorIneligibleLifecycle, self::RememberedContextStale => [
|
self::SelectorIneligibleLifecycle, self::RememberedContextStale => [
|
||||||
NextStepOption::instruction('Refresh the tenant selector and choose an eligible tenant context.', scope: 'tenant'),
|
NextStepOption::instruction('Refresh the tenant selector and choose an eligible tenant context.', scope: 'tenant'),
|
||||||
],
|
],
|
||||||
|
self::TenantRemovedFromWorkspace => [
|
||||||
|
NextStepOption::instruction('Restore the tenant to the workspace before using it as active context.', scope: 'workspace'),
|
||||||
|
],
|
||||||
|
self::WorkspaceClosed => [
|
||||||
|
NextStepOption::instruction('Reopen the workspace before using active tenant context or starting new tenant operations.', scope: 'workspace'),
|
||||||
|
],
|
||||||
self::TenantNotArchived => [
|
self::TenantNotArchived => [
|
||||||
NextStepOption::instruction('Archive the tenant before retrying this action.', scope: 'tenant'),
|
NextStepOption::instruction('Archive the tenant before retrying this action.', scope: 'tenant'),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -53,12 +53,17 @@ public static function identity(OperationRun $run): array
|
|||||||
$targetScope = $context['target_scope'] ?? [];
|
$targetScope = $context['target_scope'] ?? [];
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||||
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
|
||||||
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
if ($targetScopeIdentifier !== null) {
|
||||||
$identity['entra_tenant_id'] = trim($entraTenantId);
|
$identity['target_scope'] = self::targetScopeIdentity($targetScope);
|
||||||
|
} else {
|
||||||
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
|
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||||
|
$identity['entra_tenant_id'] = trim($entraTenantId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null);
|
$connectionType = $context['connection_type'] ?? ($targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null));
|
||||||
if (is_string($connectionType) && trim($connectionType) !== '') {
|
if (is_string($connectionType) && trim($connectionType) !== '') {
|
||||||
$identity['connection_type'] = trim($connectionType);
|
$identity['connection_type'] = trim($connectionType);
|
||||||
}
|
}
|
||||||
@ -124,15 +129,23 @@ private static function evidence(OperationRun $run, array $context): array
|
|||||||
$targetScope = $context['target_scope'] ?? [];
|
$targetScope = $context['target_scope'] ?? [];
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||||
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
|
||||||
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
if ($targetScopeIdentifier !== null) {
|
||||||
$evidence[] = [
|
$evidence[] = [
|
||||||
'kind' => 'entra_tenant_id',
|
'kind' => 'target_scope_identifier',
|
||||||
'value' => trim($entraTenantId),
|
'value' => $targetScopeIdentifier,
|
||||||
];
|
];
|
||||||
|
} else {
|
||||||
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
|
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||||
|
$evidence[] = [
|
||||||
|
'kind' => 'entra_tenant_id',
|
||||||
|
'value' => trim($entraTenantId),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null);
|
$connectionType = $context['connection_type'] ?? ($targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null));
|
||||||
if (is_string($connectionType) && trim($connectionType) !== '') {
|
if (is_string($connectionType) && trim($connectionType) !== '') {
|
||||||
$evidence[] = [
|
$evidence[] = [
|
||||||
'kind' => 'connection_type',
|
'kind' => 'connection_type',
|
||||||
@ -163,4 +176,35 @@ private static function evidence(OperationRun $run, array $context): array
|
|||||||
|
|
||||||
return $evidence;
|
return $evidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $targetScope
|
||||||
|
*/
|
||||||
|
private static function targetScopeIdentifier(array $targetScope): ?string
|
||||||
|
{
|
||||||
|
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
|
||||||
|
|
||||||
|
return is_string($scopeIdentifier) && trim($scopeIdentifier) !== ''
|
||||||
|
? trim($scopeIdentifier)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $targetScope
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function targetScopeIdentity(array $targetScope): array
|
||||||
|
{
|
||||||
|
$identity = [];
|
||||||
|
|
||||||
|
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
|
||||||
|
$value = $targetScope[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
$identity[$key] = trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $identity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,9 +45,14 @@ public static function identity(OperationRun $run): array
|
|||||||
$targetScope = $context['target_scope'] ?? [];
|
$targetScope = $context['target_scope'] ?? [];
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||||
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
|
||||||
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
if ($targetScopeIdentifier !== null) {
|
||||||
$identity['entra_tenant_id'] = trim($entraTenantId);
|
$identity['target_scope'] = self::targetScopeIdentity($targetScope);
|
||||||
|
} else {
|
||||||
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
|
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||||
|
$identity['entra_tenant_id'] = trim($entraTenantId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $identity;
|
return $identity;
|
||||||
@ -72,12 +77,20 @@ private static function evidence(OperationRun $run, array $context): array
|
|||||||
$targetScope = $context['target_scope'] ?? [];
|
$targetScope = $context['target_scope'] ?? [];
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||||
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
|
||||||
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
if ($targetScopeIdentifier !== null) {
|
||||||
$evidence[] = [
|
$evidence[] = [
|
||||||
'kind' => 'entra_tenant_id',
|
'kind' => 'target_scope_identifier',
|
||||||
'value' => trim($entraTenantId),
|
'value' => $targetScopeIdentifier,
|
||||||
];
|
];
|
||||||
|
} else {
|
||||||
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
|
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||||
|
$evidence[] = [
|
||||||
|
'kind' => 'entra_tenant_id',
|
||||||
|
'value' => trim($entraTenantId),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$evidence[] = [
|
$evidence[] = [
|
||||||
@ -87,4 +100,35 @@ private static function evidence(OperationRun $run, array $context): array
|
|||||||
|
|
||||||
return $evidence;
|
return $evidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $targetScope
|
||||||
|
*/
|
||||||
|
private static function targetScopeIdentifier(array $targetScope): ?string
|
||||||
|
{
|
||||||
|
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
|
||||||
|
|
||||||
|
return is_string($scopeIdentifier) && trim($scopeIdentifier) !== ''
|
||||||
|
? trim($scopeIdentifier)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $targetScope
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function targetScopeIdentity(array $targetScope): array
|
||||||
|
{
|
||||||
|
$identity = [];
|
||||||
|
|
||||||
|
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
|
||||||
|
$value = $targetScope[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
$identity[$key] = trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $identity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ final class VerificationReportSanitizer
|
|||||||
*/
|
*/
|
||||||
private const ALLOWED_EVIDENCE_KINDS = [
|
private const ALLOWED_EVIDENCE_KINDS = [
|
||||||
'provider_connection_id',
|
'provider_connection_id',
|
||||||
|
'target_scope_identifier',
|
||||||
'entra_tenant_id',
|
'entra_tenant_id',
|
||||||
'connection_type',
|
'connection_type',
|
||||||
'credential_source',
|
'credential_source',
|
||||||
@ -108,7 +109,7 @@ public static function sanitizeReport(array $report): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $identity
|
* @param array<string, mixed> $identity
|
||||||
* @return array<string, int|string>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static function sanitizeIdentity(array $identity): array
|
private static function sanitizeIdentity(array $identity): array
|
||||||
{
|
{
|
||||||
@ -123,6 +124,16 @@ private static function sanitizeIdentity(array $identity): array
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($key === 'target_scope' && is_array($value)) {
|
||||||
|
$targetScope = self::sanitizeIdentityTargetScope($value);
|
||||||
|
|
||||||
|
if ($targetScope !== []) {
|
||||||
|
$sanitized[$key] = $targetScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (is_int($value)) {
|
if (is_int($value)) {
|
||||||
$sanitized[$key] = $value;
|
$sanitized[$key] = $value;
|
||||||
|
|
||||||
@ -143,6 +154,31 @@ private static function sanitizeIdentity(array $identity): array
|
|||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $targetScope
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function sanitizeIdentityTargetScope(array $targetScope): array
|
||||||
|
{
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
|
||||||
|
$value = $targetScope[$key] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = self::sanitizeValueString($value);
|
||||||
|
|
||||||
|
if ($value !== null) {
|
||||||
|
$sanitized[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $summary
|
* @param array<string, mixed> $summary
|
||||||
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
|
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
|
||||||
|
|||||||
@ -84,6 +84,10 @@ public function currentWorkspaceOrTenantWorkspace(?ManagedEnvironment $tenant =
|
|||||||
|
|
||||||
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
|
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
|
||||||
{
|
{
|
||||||
|
if (! $this->isWorkspaceSelectable($workspace)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||||
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
|
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
@ -316,7 +320,7 @@ public function ensureTenantAccessibleInCurrentWorkspace(ManagedEnvironment $ten
|
|||||||
{
|
{
|
||||||
$workspace = $this->currentWorkspaceForMemberOrFail($user, $request);
|
$workspace = $this->currentWorkspaceForMemberOrFail($user, $request);
|
||||||
|
|
||||||
if ((int) $tenant->workspace_id !== (int) $workspace->getKey() || ! $user->canAccessTenant($tenant)) {
|
if ((int) $tenant->workspace_id !== (int) $workspace->getKey() || $tenant->isRemovedFromWorkspace() || ! $user->canAccessTenant($tenant)) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,7 +329,7 @@ public function ensureTenantAccessibleInCurrentWorkspace(ManagedEnvironment $ten
|
|||||||
|
|
||||||
private function isWorkspaceSelectable(Workspace $workspace): bool
|
private function isWorkspaceSelectable(Workspace $workspace): bool
|
||||||
{
|
{
|
||||||
return empty($workspace->archived_at);
|
return $workspace->isSelectableAsContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function userCanAccessTenant(ManagedEnvironment $tenant, ?Request $request = null): bool
|
private function userCanAccessTenant(ManagedEnvironment $tenant, ?Request $request = null): bool
|
||||||
|
|||||||
@ -40,18 +40,19 @@
|
|||||||
'target scope',
|
'target scope',
|
||||||
'credential source',
|
'credential source',
|
||||||
'effective client identity',
|
'effective client identity',
|
||||||
|
'provider context',
|
||||||
],
|
],
|
||||||
'retained_provider_semantics' => [
|
'retained_provider_semantics' => [
|
||||||
'entra_tenant_id',
|
'provider_context.microsoft_tenant_id',
|
||||||
'platform_config',
|
'platform_config',
|
||||||
'graph.tenant_id',
|
'graph.tenant_id',
|
||||||
'admin.consent.callback',
|
'admin.consent.callback',
|
||||||
],
|
],
|
||||||
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
|
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
|
||||||
],
|
],
|
||||||
'provider.connection_resolution' => [
|
'provider.connection_resolution' => [
|
||||||
'owner' => ProviderBoundaryOwner::PlatformCore->value,
|
'owner' => ProviderBoundaryOwner::PlatformCore->value,
|
||||||
'description' => 'Platform-core provider connection selection and validation path that keeps current Microsoft connection details as bounded exception metadata.',
|
'description' => 'Platform-core provider connection selection and validation path that publishes neutral target-scope truth with provider-specific profile detail kept as bounded context metadata.',
|
||||||
'implementation_paths' => [
|
'implementation_paths' => [
|
||||||
'app/Services/Providers/ProviderConnectionResolver.php',
|
'app/Services/Providers/ProviderConnectionResolver.php',
|
||||||
'app/Services/Providers/ProviderConnectionResolution.php',
|
'app/Services/Providers/ProviderConnectionResolution.php',
|
||||||
@ -59,16 +60,16 @@
|
|||||||
'neutral_terms' => [
|
'neutral_terms' => [
|
||||||
'provider',
|
'provider',
|
||||||
'provider connection',
|
'provider connection',
|
||||||
'tenant scope',
|
'target scope',
|
||||||
'default binding',
|
'default binding',
|
||||||
'unsupported combination',
|
'unsupported combination',
|
||||||
],
|
],
|
||||||
'retained_provider_semantics' => [
|
'retained_provider_semantics' => [
|
||||||
'microsoft',
|
'microsoft',
|
||||||
'entra_tenant_id',
|
'provider_context.microsoft_tenant_id',
|
||||||
'consent_status',
|
'consent_status',
|
||||||
],
|
],
|
||||||
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
|
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
|
||||||
],
|
],
|
||||||
'provider.operation_registry' => [
|
'provider.operation_registry' => [
|
||||||
'owner' => ProviderBoundaryOwner::PlatformCore->value,
|
'owner' => ProviderBoundaryOwner::PlatformCore->value,
|
||||||
@ -94,7 +95,7 @@
|
|||||||
],
|
],
|
||||||
'provider.operation_start_gate' => [
|
'provider.operation_start_gate' => [
|
||||||
'owner' => ProviderBoundaryOwner::PlatformCore->value,
|
'owner' => ProviderBoundaryOwner::PlatformCore->value,
|
||||||
'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records current Microsoft target-scope exceptions.',
|
'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records neutral target-scope context with provider-specific follow-up detail nested separately.',
|
||||||
'implementation_paths' => [
|
'implementation_paths' => [
|
||||||
'app/Services/Providers/ProviderOperationStartGate.php',
|
'app/Services/Providers/ProviderOperationStartGate.php',
|
||||||
],
|
],
|
||||||
@ -107,9 +108,9 @@
|
|||||||
],
|
],
|
||||||
'retained_provider_semantics' => [
|
'retained_provider_semantics' => [
|
||||||
'microsoft',
|
'microsoft',
|
||||||
'target_scope.entra_tenant_id',
|
'provider_context.microsoft_tenant_id',
|
||||||
],
|
],
|
||||||
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
|
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('workspaces', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('closed_at')->nullable()->after('archived_at');
|
||||||
|
$table->foreignId('closed_by_platform_user_id')->nullable()->after('closed_at')->constrained('platform_users')->nullOnDelete();
|
||||||
|
$table->text('closed_reason')->nullable()->after('closed_by_platform_user_id');
|
||||||
|
|
||||||
|
$table->index('closed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('workspaces', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['closed_at']);
|
||||||
|
$table->dropConstrainedForeignId('closed_by_platform_user_id');
|
||||||
|
$table->dropColumn(['closed_at', 'closed_reason']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('managed_environments', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('removed_from_workspace_at')->nullable()->after('deleted_at');
|
||||||
|
$table->foreignId('removed_from_workspace_by_user_id')->nullable()->after('removed_from_workspace_at')->constrained('users')->nullOnDelete();
|
||||||
|
$table->text('removed_from_workspace_reason')->nullable()->after('removed_from_workspace_by_user_id');
|
||||||
|
|
||||||
|
$table->index('removed_from_workspace_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DB::getDriverName() === 'sqlite') {
|
||||||
|
DB::statement('DROP INDEX IF EXISTS tenants_current_unique');
|
||||||
|
DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON managed_environments (is_current) WHERE is_current = 1 AND deleted_at IS NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('managed_environments', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['removed_from_workspace_at']);
|
||||||
|
$table->dropConstrainedForeignId('removed_from_workspace_by_user_id');
|
||||||
|
$table->dropColumn([
|
||||||
|
'removed_from_workspace_at',
|
||||||
|
'removed_from_workspace_reason',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -173,17 +173,25 @@
|
|||||||
'kpi_missing_permissions_tendency_app_only' => ':count App-Berechtigungen fehlen',
|
'kpi_missing_permissions_tendency_app_only' => ':count App-Berechtigungen fehlen',
|
||||||
'kpi_missing_permissions_tendency_delegated_only' => ':count delegierte Berechtigungen fehlen',
|
'kpi_missing_permissions_tendency_delegated_only' => ':count delegierte Berechtigungen fehlen',
|
||||||
'kpi_missing_permissions_tendency_none' => 'Berechtigungen vollständig',
|
'kpi_missing_permissions_tendency_none' => 'Berechtigungen vollständig',
|
||||||
'kpi_active_operations_label' => 'Aktive Vorgänge',
|
'kpi_active_operations_label' => 'Vorgänge mit Aufmerksamkeitsbedarf',
|
||||||
'kpi_active_operations_description' => 'Veraltete oder terminale Vorgänge benötigen Operator-Nachverfolgung.',
|
'kpi_active_operations_description' => 'Vorgangsläufe, die noch Follow-up benötigen, bevor der Tenant als gesund gelten kann.',
|
||||||
'kpi_active_operations_tendency' => ':count mit Follow-up',
|
'kpi_active_operations_tendency' => ':count Vorgänge erfordern Aufmerksamkeit',
|
||||||
'kpi_active_operations_tendency_window' => ':count mit Follow-up · :window in 7 Tagen',
|
'kpi_active_operations_tendency_window' => ':count Vorgänge erfordern Aufmerksamkeit',
|
||||||
'kpi_active_operations_tendency_none' => 'Kein Follow-up offen',
|
'kpi_active_operations_tendency_one' => '1 Vorgang benötigt Follow-up',
|
||||||
|
'kpi_active_operations_tendency_none' => 'Keine Vorgänge benötigen Aufmerksamkeit',
|
||||||
'action_review_findings' => 'Findings prüfen',
|
'action_review_findings' => 'Findings prüfen',
|
||||||
'action_open_overdue_findings' => 'Überfällige Findings öffnen',
|
'action_open_overdue_findings' => 'Überfällige Findings öffnen',
|
||||||
|
'action_review_permissions' => 'Berechtigungen prüfen',
|
||||||
'action_open_required_permissions' => 'Erforderliche Berechtigungen öffnen',
|
'action_open_required_permissions' => 'Erforderliche Berechtigungen öffnen',
|
||||||
'action_review_risks' => 'Risiken prüfen',
|
'action_review_risks' => 'Risiken prüfen',
|
||||||
'action_review_recovery_posture' => 'Wiederherstellungsstatus prüfen',
|
'action_review_recovery_posture' => 'Wiederherstellungsstatus prüfen',
|
||||||
'action_view_all_operations' => 'Alle Vorgänge anzeigen',
|
'action_view_all_operations' => 'Alle Vorgänge anzeigen',
|
||||||
|
'action_view_operation' => 'Vorgang anzeigen',
|
||||||
|
'action_review_operation' => 'Vorgang prüfen',
|
||||||
|
'action_review_operations' => 'Vorgänge prüfen',
|
||||||
|
'action_review_operations_requiring_attention' => 'Aufmerksamkeitspflichtige Vorgänge prüfen',
|
||||||
|
'action_open_operations_hub' => 'Operations-Hub öffnen',
|
||||||
|
'action_show_all_operations' => 'Alle Vorgänge anzeigen',
|
||||||
'action_open_governance_inbox' => 'Governance Inbox öffnen',
|
'action_open_governance_inbox' => 'Governance Inbox öffnen',
|
||||||
'action_continue_review' => 'Review fortsetzen',
|
'action_continue_review' => 'Review fortsetzen',
|
||||||
'action_open_baseline_compare' => 'Baseline Compare öffnen',
|
'action_open_baseline_compare' => 'Baseline Compare öffnen',
|
||||||
@ -207,8 +215,27 @@
|
|||||||
'impact_recovery_posture' => 'Die Wiederherstellungsbereitschaft sollte geprüft werden, bevor kundensichere Aussagen auf Backup- oder Restore-Vertrauen beruhen.',
|
'impact_recovery_posture' => 'Die Wiederherstellungsbereitschaft sollte geprüft werden, bevor kundensichere Aussagen auf Backup- oder Restore-Vertrauen beruhen.',
|
||||||
'reason_terminal_operations' => ':count Vorgangslauf/Läufe endeten blockiert, teilweise oder fehlgeschlagen.',
|
'reason_terminal_operations' => ':count Vorgangslauf/Läufe endeten blockiert, teilweise oder fehlgeschlagen.',
|
||||||
'impact_terminal_operations' => 'Terminale Laufergebnisse benötigen Nachverfolgung, bevor der Tenant als ruhig gelten kann.',
|
'impact_terminal_operations' => 'Terminale Laufergebnisse benötigen Nachverfolgung, bevor der Tenant als ruhig gelten kann.',
|
||||||
|
'reason_operations_requiring_attention' => 'Ein oder mehrere Vorgänge endeten mit einem Ergebnis, das Follow-up benötigt.',
|
||||||
|
'impact_operations_requiring_attention' => 'Der Tenant sollte nicht als vollständig gesund betrachtet werden, bis das Vorgangsergebnis geprüft wurde.',
|
||||||
'reason_continue_review' => 'Kundensichere Ausgabe ist noch nicht vollständig bereit.',
|
'reason_continue_review' => 'Kundensichere Ausgabe ist noch nicht vollständig bereit.',
|
||||||
'impact_continue_review' => 'Die Review-Ausgabe bleibt teilweise, bis Review-, Nachweis- und Paketflächen sauber zusammenpassen.',
|
'impact_continue_review' => 'Die Review-Ausgabe bleibt teilweise, bis Review-, Nachweis- und Paketflächen sauber zusammenpassen.',
|
||||||
|
'operations_attention_title' => 'Vorgänge mit Aufmerksamkeitsbedarf',
|
||||||
|
'operations_attention_badge_follow_up' => 'Follow-up erforderlich',
|
||||||
|
'operations_attention_badge_stale' => 'Aufmerksamkeit nötig',
|
||||||
|
'operations_attention_outcome_blocked' => 'Der Vorgang wurde beendet, aber eine Voraussetzung hat den Abschluss blockiert.',
|
||||||
|
'operations_attention_outcome_partial' => 'Der Vorgang wurde beendet, aber es ist weiterhin Follow-up erforderlich.',
|
||||||
|
'operations_attention_outcome_failed' => 'Der Vorgang endete mit einem Fehler, der geprüft werden muss.',
|
||||||
|
'operations_attention_outcome_generic' => 'Der Vorgang endete mit einem Ergebnis, das Nachverfolgung benötigt.',
|
||||||
|
'operations_attention_outcome_stale' => 'Der Vorgang ist noch aktiv, liegt aber außerhalb seines erwarteten Lebenszyklusfensters.',
|
||||||
|
'operations_attention_outcome_provider_consent_required' => 'Die Prüfung ist abgeschlossen, aber die Provider-Zustimmung ist noch erforderlich.',
|
||||||
|
'operations_attention_reason_fallback' => 'Das aufgezeichnete Ergebnis muss geprüft werden, bevor der Tenant als gesund gelten kann.',
|
||||||
|
'operations_attention_reason_stale' => 'Der Lauf liegt außerhalb seines normalen Lebenszyklusfensters und schreitet möglicherweise nicht mehr fort.',
|
||||||
|
'operations_attention_reason_provider_consent_required' => 'Eine Admin-Zustimmung ist erforderlich, bevor die Provider-Verbindung verwendet werden kann.',
|
||||||
|
'operations_attention_impact_follow_up' => 'Die Tenant-Bereitschaft sollte nicht als vollständig gesund betrachtet werden, bis das Vorgangsergebnis geprüft wurde.',
|
||||||
|
'operations_attention_impact_stale' => 'Die Tenant-Bereitschaft sollte nicht als aktuell betrachtet werden, bis der blockierte Lauf geprüft wurde.',
|
||||||
|
'operations_attention_impact_provider_consent_required' => 'Die Tenant-Bereitschaft kann nicht als gesund betrachtet werden, bis dies geprüft wurde.',
|
||||||
|
'operations_attention_timing_completed' => 'Abgeschlossen :time',
|
||||||
|
'operations_attention_timing_started' => 'Gestartet :time',
|
||||||
'governance_baseline_compare_label' => 'Baseline Compare',
|
'governance_baseline_compare_label' => 'Baseline Compare',
|
||||||
'governance_baseline_compare_description' => 'Aktueller Compare-Status für die Tenant-Baseline.',
|
'governance_baseline_compare_description' => 'Aktueller Compare-Status für die Tenant-Baseline.',
|
||||||
'governance_evidence_coverage_label' => 'Nachweisabdeckung',
|
'governance_evidence_coverage_label' => 'Nachweisabdeckung',
|
||||||
|
|||||||
@ -173,17 +173,25 @@
|
|||||||
'kpi_missing_permissions_tendency_app_only' => ':count app missing',
|
'kpi_missing_permissions_tendency_app_only' => ':count app missing',
|
||||||
'kpi_missing_permissions_tendency_delegated_only' => ':count delegated missing',
|
'kpi_missing_permissions_tendency_delegated_only' => ':count delegated missing',
|
||||||
'kpi_missing_permissions_tendency_none' => 'Permission set complete',
|
'kpi_missing_permissions_tendency_none' => 'Permission set complete',
|
||||||
'kpi_active_operations_label' => 'Active operations',
|
'kpi_active_operations_label' => 'Operations needing attention',
|
||||||
'kpi_active_operations_description' => 'Stale or terminal operation runs needing operator follow-up.',
|
'kpi_active_operations_description' => 'Operation runs that still need follow-up before the tenant can be treated as healthy.',
|
||||||
'kpi_active_operations_tendency' => ':count need follow-up',
|
'kpi_active_operations_tendency' => ':count operations require attention',
|
||||||
'kpi_active_operations_tendency_window' => ':count need follow-up · :window in 7d',
|
'kpi_active_operations_tendency_window' => ':count operations require attention',
|
||||||
'kpi_active_operations_tendency_none' => 'No follow-up queued',
|
'kpi_active_operations_tendency_one' => '1 operation needs follow-up',
|
||||||
|
'kpi_active_operations_tendency_none' => 'No operations need attention',
|
||||||
'action_review_findings' => 'Review findings',
|
'action_review_findings' => 'Review findings',
|
||||||
'action_open_overdue_findings' => 'Open overdue findings',
|
'action_open_overdue_findings' => 'Open overdue findings',
|
||||||
|
'action_review_permissions' => 'Review permissions',
|
||||||
'action_open_required_permissions' => 'Open required permissions',
|
'action_open_required_permissions' => 'Open required permissions',
|
||||||
'action_review_risks' => 'Review risks',
|
'action_review_risks' => 'Review risks',
|
||||||
'action_review_recovery_posture' => 'Review recovery posture',
|
'action_review_recovery_posture' => 'Review recovery posture',
|
||||||
'action_view_all_operations' => 'View all operations',
|
'action_view_all_operations' => 'View all operations',
|
||||||
|
'action_view_operation' => 'View operation',
|
||||||
|
'action_review_operation' => 'Review operation',
|
||||||
|
'action_review_operations' => 'Review operations',
|
||||||
|
'action_review_operations_requiring_attention' => 'Review operations requiring attention',
|
||||||
|
'action_open_operations_hub' => 'Open operations hub',
|
||||||
|
'action_show_all_operations' => 'Show all operations',
|
||||||
'action_open_governance_inbox' => 'Open governance inbox',
|
'action_open_governance_inbox' => 'Open governance inbox',
|
||||||
'action_continue_review' => 'Continue review',
|
'action_continue_review' => 'Continue review',
|
||||||
'action_open_baseline_compare' => 'Open Baseline Compare',
|
'action_open_baseline_compare' => 'Open Baseline Compare',
|
||||||
@ -207,8 +215,27 @@
|
|||||||
'impact_recovery_posture' => 'Recovery readiness should be checked before customer-safe claims rely on backup or restore confidence.',
|
'impact_recovery_posture' => 'Recovery readiness should be checked before customer-safe claims rely on backup or restore confidence.',
|
||||||
'reason_terminal_operations' => ':count operation run(s) finished blocked, partial, or failed.',
|
'reason_terminal_operations' => ':count operation run(s) finished blocked, partial, or failed.',
|
||||||
'impact_terminal_operations' => 'Terminal run outcomes need follow-up before the tenant can be treated as calm.',
|
'impact_terminal_operations' => 'Terminal run outcomes need follow-up before the tenant can be treated as calm.',
|
||||||
|
'reason_operations_requiring_attention' => 'One or more operations finished with an outcome that needs follow-up.',
|
||||||
|
'impact_operations_requiring_attention' => 'The tenant should not be treated as fully healthy until the operation outcome has been reviewed.',
|
||||||
'reason_continue_review' => 'Customer-safe output is not fully ready yet.',
|
'reason_continue_review' => 'Customer-safe output is not fully ready yet.',
|
||||||
'impact_continue_review' => 'Review output stays partial until the review, evidence, and pack surfaces line up cleanly.',
|
'impact_continue_review' => 'Review output stays partial until the review, evidence, and pack surfaces line up cleanly.',
|
||||||
|
'operations_attention_title' => 'Operations requiring attention',
|
||||||
|
'operations_attention_badge_follow_up' => 'Follow-up required',
|
||||||
|
'operations_attention_badge_stale' => 'Needs attention',
|
||||||
|
'operations_attention_outcome_blocked' => 'The operation finished, but a prerequisite blocked completion.',
|
||||||
|
'operations_attention_outcome_partial' => 'The operation finished, but follow-up is still required.',
|
||||||
|
'operations_attention_outcome_failed' => 'The operation finished with a failure that needs review.',
|
||||||
|
'operations_attention_outcome_generic' => 'The operation finished with an outcome that needs follow-up.',
|
||||||
|
'operations_attention_outcome_stale' => 'The operation is still active, but it is past its expected lifecycle window.',
|
||||||
|
'operations_attention_outcome_provider_consent_required' => 'The check finished, but provider consent is still required.',
|
||||||
|
'operations_attention_reason_fallback' => 'The recorded outcome still needs operator review before the tenant can be treated as healthy.',
|
||||||
|
'operations_attention_reason_stale' => 'The run is past its normal lifecycle window and may no longer be progressing.',
|
||||||
|
'operations_attention_reason_provider_consent_required' => 'Admin consent is required before the provider connection can be used.',
|
||||||
|
'operations_attention_impact_follow_up' => 'Tenant readiness should not be treated as fully healthy until the operation outcome has been reviewed.',
|
||||||
|
'operations_attention_impact_stale' => 'Tenant readiness should not be treated as current until the stalled run has been reviewed.',
|
||||||
|
'operations_attention_impact_provider_consent_required' => 'Tenant readiness cannot be treated as healthy until this is reviewed.',
|
||||||
|
'operations_attention_timing_completed' => 'Completed :time',
|
||||||
|
'operations_attention_timing_started' => 'Started :time',
|
||||||
'governance_baseline_compare_label' => 'Baseline compare',
|
'governance_baseline_compare_label' => 'Baseline compare',
|
||||||
'governance_baseline_compare_description' => 'Current compare posture for the tenant baseline.',
|
'governance_baseline_compare_description' => 'Current compare posture for the tenant baseline.',
|
||||||
'governance_evidence_coverage_label' => 'Evidence coverage',
|
'governance_evidence_coverage_label' => 'Evidence coverage',
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
|
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
|
||||||
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
|
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
|
||||||
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
|
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
|
||||||
|
$targetScopeSummary = is_string($state['target_scope_summary'] ?? null) ? (string) $state['target_scope_summary'] : null;
|
||||||
|
|
||||||
$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);
|
||||||
@ -26,9 +27,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
|
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
|
||||||
@if ($isMissing)
|
@if ($isMissing)
|
||||||
<div class="mt-1 text-sm text-amber-700">Needs action: no Microsoft provider connection is configured.</div>
|
<div class="mt-1 text-sm text-amber-700">Needs action: no provider connection is configured.</div>
|
||||||
@elseif ($needsDefaultConnection)
|
@elseif ($needsDefaultConnection)
|
||||||
<div class="mt-1 text-sm text-amber-700">Needs action: set a default Microsoft provider connection.</div>
|
<div class="mt-1 text-sm text-amber-700">Needs action: set a default provider connection.</div>
|
||||||
@else
|
@else
|
||||||
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
|
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
|
||||||
@endif
|
@endif
|
||||||
@ -51,6 +52,10 @@
|
|||||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
|
||||||
<dd>{{ $provider ?? 'n/a' }}</dd>
|
<dd>{{ $provider ?? 'n/a' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Target scope</dt>
|
||||||
|
<dd>{{ $targetScopeSummary ?? 'n/a' }}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Lifecycle</dt>
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Lifecycle</dt>
|
||||||
<dd>
|
<dd>
|
||||||
|
|||||||
@ -47,7 +47,7 @@ class="h-6 w-6 text-gray-400 dark:text-gray-500"
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No workspaces available</h3>
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No workspaces available</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
You don't have access to any workspace yet. Contact your administrator to get started.
|
You don't have access to any workspace yet. Closed workspaces stay available for administrative history but cannot be selected as active context.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
$providerConnections = $this->providerConnections();
|
$providerConnections = $this->providerConnections();
|
||||||
$permissions = $this->tenantPermissions();
|
$permissions = $this->tenantPermissions();
|
||||||
$runs = $this->recentRuns();
|
$runs = $this->recentRuns();
|
||||||
|
$workspacePostureValue = $tenant->isRemovedFromWorkspace() ? 'removed_from_workspace' : 'active';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@ -26,11 +27,26 @@
|
|||||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }}
|
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge
|
||||||
|
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantWorkspacePosture, $workspacePostureValue)->color"
|
||||||
|
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantWorkspacePosture, $workspacePostureValue)->icon"
|
||||||
|
>
|
||||||
|
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantWorkspacePosture, $workspacePostureValue)->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
@if ($tenant->external_id)
|
@if ($tenant->external_id)
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">External ID: {{ $tenant->external_id }}</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">External ID: {{ $tenant->external_id }}</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($tenant->isRemovedFromWorkspace())
|
||||||
|
<div class="mt-4 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
|
||||||
|
<p class="font-semibold">Removed from workspace</p>
|
||||||
|
<p class="mt-1">{{ $tenant->workspaceRemovalReason() ?? 'No removal reason recorded.' }}</p>
|
||||||
|
<p class="mt-1">Active tenant selection and new tenant operations are blocked. Audit, evidence, and operation history remain visible.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||||
Open in tenant admin
|
Open in tenant admin
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
$runs = $this->recentRuns();
|
$runs = $this->recentRuns();
|
||||||
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
|
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
|
||||||
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
|
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
|
||||||
|
$workspaceClosurePosture = $workspace->isClosed() ? 'closed' : 'open';
|
||||||
|
$workspaceClosureBadge = BadgeCatalog::spec(BadgeDomain::WorkspaceClosurePosture, $workspaceClosurePosture);
|
||||||
$commercialSourceDescriptor = ($commercialLifecycle['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed';
|
$commercialSourceDescriptor = ($commercialLifecycle['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed';
|
||||||
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
|
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
|
||||||
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
|
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
|
||||||
@ -38,8 +40,30 @@
|
|||||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tenants</p>
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tenants</p>
|
||||||
<p class="mt-1 text-2xl font-bold text-gray-950 dark:text-white">{{ number_format((int) $workspace->tenants_count) }}</p>
|
<p class="mt-1 text-2xl font-bold text-gray-950 dark:text-white">{{ number_format((int) $workspace->tenants_count) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Lifecycle</p>
|
||||||
|
<div class="mt-2">
|
||||||
|
<x-filament::badge :color="$workspaceClosureBadge->color" :icon="$workspaceClosureBadge->icon">
|
||||||
|
{{ $workspaceClosureBadge->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
@if ($workspace->isClosed())
|
||||||
|
Closed {{ $workspace->closed_at?->diffForHumans() ?? '' }}. Active selection and new mutations are blocked.
|
||||||
|
@else
|
||||||
|
Eligible for workspace selection and normal tenant operations.
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($workspace->isClosed())
|
||||||
|
<div class="mt-4 rounded-lg border border-danger-200 bg-danger-50 px-4 py-3 text-sm text-danger-800 dark:border-danger-500/30 dark:bg-danger-500/10 dark:text-danger-200">
|
||||||
|
<p class="font-semibold">Closed workspace</p>
|
||||||
|
<p class="mt-1">{{ $workspace->closureReason() ?? 'No closure reason recorded.' }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<x-filament::link :href="$this->adminWorkspaceUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
<x-filament::link :href="$this->adminWorkspaceUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||||
Open in /admin
|
Open in /admin
|
||||||
@ -272,9 +296,9 @@ class="flex items-center justify-between rounded-lg border border-gray-200 px-4
|
|||||||
>
|
>
|
||||||
<span class="font-medium text-gray-950 dark:text-white">{{ $tenant->name }}</span>
|
<span class="font-medium text-gray-950 dark:text-white">{{ $tenant->name }}</span>
|
||||||
<x-filament::badge
|
<x-filament::badge
|
||||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->color"
|
:color="\App\Support\Badges\BadgeRenderer::spec($tenant->isRemovedFromWorkspace() ? \App\Support\Badges\BadgeDomain::TenantWorkspacePosture : \App\Support\Badges\BadgeDomain::TenantStatus, $tenant->isRemovedFromWorkspace() ? 'removed_from_workspace' : (string) $tenant->status)->color"
|
||||||
>
|
>
|
||||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }}
|
{{ \App\Support\Badges\BadgeRenderer::spec($tenant->isRemovedFromWorkspace() ? \App\Support\Badges\BadgeDomain::TenantWorkspacePosture : \App\Support\Badges\BadgeDomain::TenantStatus, $tenant->isRemovedFromWorkspace() ? 'removed_from_workspace' : (string) $tenant->status)->label }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@ -24,6 +24,14 @@
|
|||||||
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
|
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
|
||||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||||
$decisionTruth = \App\Support\OpsUx\OperationUxPresenter::decisionZoneTruth($run);
|
$decisionTruth = \App\Support\OpsUx\OperationUxPresenter::decisionZoneTruth($run);
|
||||||
|
$workspaceClosureSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
|
\App\Support\Badges\BadgeDomain::WorkspaceClosurePosture,
|
||||||
|
$run->workspace?->isClosed() ? 'closed' : 'open',
|
||||||
|
);
|
||||||
|
$tenantWorkspacePostureSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
|
\App\Support\Badges\BadgeDomain::TenantWorkspacePosture,
|
||||||
|
$run->tenant?->isRemovedFromWorkspace() ? 'removed_from_workspace' : 'active',
|
||||||
|
);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@ -72,6 +80,26 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Workspace lifecycle</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$workspaceClosureSpec->color" :icon="$workspaceClosureSpec->icon">
|
||||||
|
{{ $workspaceClosureSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($run->tenant)
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tenant workspace posture</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$tenantWorkspacePostureSpec->color" :icon="$tenantWorkspacePostureSpec->icon">
|
||||||
|
{{ $tenantWorkspacePostureSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Started</dt>
|
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Started</dt>
|
||||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
|||||||
@ -3,15 +3,15 @@
|
|||||||
wire:poll.{{ $pollingInterval }}
|
wire:poll.{{ $pollingInterval }}
|
||||||
@endif
|
@endif
|
||||||
data-testid="tenant-dashboard-context-chips"
|
data-testid="tenant-dashboard-context-chips"
|
||||||
class="grid gap-3 md:grid-cols-2 lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:items-center"
|
class="flex w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-start md:flex-nowrap"
|
||||||
>
|
>
|
||||||
<div data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full items-center gap-2 rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200">
|
<div data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full max-w-full items-center gap-2 rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200 sm:w-auto sm:max-w-[20rem] lg:max-w-[24rem]">
|
||||||
<x-filament::icon icon="heroicon-o-building-office" class="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
<x-filament::icon icon="heroicon-o-building-office" class="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||||
<span class="truncate" title="{{ __('localization.dashboard.overview.context_workspace_chip', ['workspace' => $context['workspace']]) }}">{{ __('localization.dashboard.overview.context_workspace_chip', ['workspace' => $context['workspace']]) }}</span>
|
<span class="truncate" title="{{ __('localization.dashboard.overview.context_workspace_chip', ['workspace' => $context['workspace']]) }}">{{ __('localization.dashboard.overview.context_workspace_chip', ['workspace' => $context['workspace']]) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (filled($context['provider'] ?? null))
|
@if (filled($context['provider'] ?? null))
|
||||||
<div data-testid="tenant-dashboard-context-chip-provider" data-provider-key="{{ $context['providerKey'] ?? '' }}" class="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200 lg:justify-self-start">
|
<div data-testid="tenant-dashboard-context-chip-provider" data-provider-key="{{ $context['providerKey'] ?? '' }}" class="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200">
|
||||||
@if (($context['providerKey'] ?? null) === 'microsoft')
|
@if (($context['providerKey'] ?? null) === 'microsoft')
|
||||||
<svg data-testid="tenant-dashboard-context-chip-provider-microsoft-logo" viewBox="0 0 16 16" aria-hidden="true" class="h-4 w-4 shrink-0">
|
<svg data-testid="tenant-dashboard-context-chip-provider-microsoft-logo" viewBox="0 0 16 16" aria-hidden="true" class="h-4 w-4 shrink-0">
|
||||||
<rect x="1" y="1" width="6" height="6" fill="#f25022" />
|
<rect x="1" y="1" width="6" height="6" fill="#f25022" />
|
||||||
@ -28,7 +28,7 @@ class="grid gap-3 md:grid-cols-2 lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:i
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($context['latestActivity'] ?? null))
|
@if (filled($context['latestActivity'] ?? null))
|
||||||
<div data-testid="tenant-dashboard-context-chip-latest-activity" class="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200 lg:justify-self-start">
|
<div data-testid="tenant-dashboard-context-chip-latest-activity" class="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200">
|
||||||
<x-filament::icon data-testid="tenant-dashboard-context-chip-latest-activity-icon" icon="heroicon-o-clock" class="h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500" />
|
<x-filament::icon data-testid="tenant-dashboard-context-chip-latest-activity-icon" icon="heroicon-o-clock" class="h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||||
<span>{{ __('localization.dashboard.overview.context_latest_activity_chip', ['time' => $context['latestActivity']]) }}</span>
|
<span>{{ __('localization.dashboard.overview.context_latest_activity_chip', ['time' => $context['latestActivity']]) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -138,61 +138,82 @@ class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
|||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
<!-- Recent Operations -->
|
@if ($activeOperationSummary)
|
||||||
<x-filament::section :heading="__('localization.dashboard.overview.section_recent_operations')">
|
<div
|
||||||
@if ($recentOperations === [])
|
data-testid="tenant-dashboard-operations-attention-summary"
|
||||||
<div data-testid="tenant-dashboard-recent-operations-empty" class="rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-white/10 dark:bg-white/5">
|
class="min-w-0 rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5"
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ __('localization.dashboard.overview.empty_recent_operations_headline') }}</div>
|
>
|
||||||
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
<div class="flex flex-col gap-4">
|
||||||
{{ __('localization.dashboard.overview.empty_recent_operations_summary') }}
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
</p>
|
<div class="min-w-0 flex-1">
|
||||||
</div>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@else
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ $activeOperationSummary['title'] }}</div>
|
||||||
<div class="flex flex-col gap-3">
|
<x-filament::badge :color="$activeOperationSummary['tone']">{{ $activeOperationSummary['count'] }}</x-filament::badge>
|
||||||
@foreach (array_slice($recentOperations, 0, 4) as $operation)
|
</div>
|
||||||
@php
|
</div>
|
||||||
$operationTone = match ($operation['outcomeTone']) {
|
|
||||||
'danger' => 'border-danger-200 bg-danger-50/10 dark:border-danger-800 dark:bg-danger-500/5',
|
<div class="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||||
'warning' => 'border-warning-200 bg-warning-50/10 dark:border-warning-800 dark:bg-warning-500/5',
|
<x-filament::button
|
||||||
default => $overviewSecondaryListRowSurfaceClasses,
|
data-testid="tenant-dashboard-operations-attention-secondary-action"
|
||||||
};
|
tag="a"
|
||||||
@endphp
|
:href="$activeOperationSummary['secondaryActionUrl']"
|
||||||
<a
|
size="sm"
|
||||||
data-testid="tenant-dashboard-recent-operation"
|
color="gray"
|
||||||
data-overview-row-style="secondary-list-row"
|
>
|
||||||
href="{{ $operation['url'] }}"
|
{{ $activeOperationSummary['secondaryActionLabel'] }}
|
||||||
class="{{ $overviewSecondaryListRowBaseClasses }} {{ $overviewSecondaryListInteractiveClasses }} {{ $operationTone }}"
|
</x-filament::button>
|
||||||
>
|
</div>
|
||||||
<div class="flex items-start justify-between gap-3">
|
</div>
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-col gap-3">
|
||||||
@if (filled($operation['icon'] ?? null))
|
@foreach ($activeOperationSummary['items'] ?? [] as $operation)
|
||||||
<x-filament::icon
|
<div data-testid="tenant-dashboard-operations-attention-item" class="rounded-xl border border-gray-200 border-l-4 border-l-warning-400 bg-gray-50/70 p-4 dark:border-white/10 dark:border-l-warning-500 dark:bg-white/5">
|
||||||
data-testid="tenant-dashboard-recent-operation-icon"
|
<div class="flex items-start justify-between gap-4 max-sm:flex-col max-sm:items-stretch">
|
||||||
data-operation-id="{{ $operation['id'] }}"
|
<div class="min-w-0 flex-1">
|
||||||
data-icon="{{ $operation['icon'] }}"
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
:icon="$operation['icon']"
|
@if (filled($operation['icon'] ?? null))
|
||||||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
<x-filament::icon
|
||||||
/>
|
data-testid="tenant-dashboard-operations-attention-item-icon"
|
||||||
|
data-icon="{{ $operation['icon'] }}"
|
||||||
|
:icon="$operation['icon']"
|
||||||
|
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $operation['title'] }}</div>
|
||||||
|
|
||||||
|
@if (filled($operation['attentionLabel'] ?? null))
|
||||||
|
<x-filament::badge color="warning">{{ $operation['attentionLabel'] }}</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm leading-6 text-gray-700 dark:text-gray-300">{{ $operation['outcomeSentence'] }}</p>
|
||||||
|
|
||||||
|
@if (filled($operation['timingLabel'] ?? null))
|
||||||
|
<div class="mt-2 text-xs font-medium text-gray-500 dark:text-gray-400">{{ $operation['timingLabel'] }}</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $operation['type'] }}</div>
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400"><span class="font-medium text-gray-700 dark:text-gray-300">{{ __('localization.dashboard.overview.label_reason') }}:</span> {{ $operation['reason'] }}</p>
|
||||||
<x-filament::badge :color="$operation['statusTone']">{{ $operation['statusLabel'] }}</x-filament::badge>
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"><span class="font-medium text-gray-700 dark:text-gray-300">{{ __('localization.dashboard.overview.label_impact') }}:</span> {{ $operation['impact'] }}</p>
|
||||||
<x-filament::badge :color="$operation['outcomeTone']">{{ $operation['outcomeLabel'] }}</x-filament::badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $operation['summary'] }}
|
<div class="shrink-0 max-sm:ml-0 sm:ml-4">
|
||||||
|
<x-filament::button
|
||||||
|
data-testid="tenant-dashboard-operations-attention-item-action"
|
||||||
|
tag="a"
|
||||||
|
:href="$operation['primaryActionUrl']"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ $operation['primaryActionLabel'] }}
|
||||||
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
@if ($operation['createdAt']) {{ $operation['createdAt'] }} @endif
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
@endforeach
|
||||||
@endforeach
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
</div>
|
||||||
</x-filament::section>
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column (Aside) -->
|
<!-- Right Column (Aside) -->
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
'status' => Finding::STATUS_NEW,
|
'status' => Finding::STATUS_NEW,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
OperationRun::factory()->create([
|
$operation = OperationRun::factory()->create([
|
||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory_sync',
|
||||||
@ -72,31 +72,45 @@
|
|||||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity\"]') !== null", true)
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity\"]') !== null", true)
|
||||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity-icon\"]') !== null", true)
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity-icon\"]') !== null", true)
|
||||||
->assertScript("(() => { const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); const firstKpi = document.querySelector('[data-testid=\"tenant-dashboard-kpi\"]'); if (! chips || ! firstKpi) return false; return chips.getBoundingClientRect().top < firstKpi.getBoundingClientRect().top; })()", true)
|
->assertScript("(() => { const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); const firstKpi = document.querySelector('[data-testid=\"tenant-dashboard-kpi\"]'); if (! chips || ! firstKpi) return false; return chips.getBoundingClientRect().top < firstKpi.getBoundingClientRect().top; })()", true)
|
||||||
|
->assertScript("(() => { const subtitle = Array.from(document.querySelectorAll('p')).find((node) => node.textContent?.includes('Tenant governance overview')); const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); if (! subtitle || ! chips) return false; return chips.getBoundingClientRect().top - subtitle.getBoundingClientRect().bottom <= 40; })()", true)
|
||||||
|
->assertScript("(() => { const workspace = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-workspace\"]'); const provider = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-provider\"]'); const activity = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity\"]'); if (! workspace || ! provider || ! activity) return false; const tops = [workspace, provider, activity].map((element) => Math.round(element.getBoundingClientRect().top)); return Math.max(...tops) - Math.min(...tops) <= 2; })()", true)
|
||||||
->assertSee('Recommended next actions')
|
->assertSee('Recommended next actions')
|
||||||
->assertSee('Active operations')
|
->assertSee('Operations needing attention')
|
||||||
|
->assertSee('Operations requiring attention')
|
||||||
|
->assertSee('Review operation')
|
||||||
|
->assertSee('Open operations hub')
|
||||||
->assertSee('Current review')
|
->assertSee('Current review')
|
||||||
->assertSee('Risk exceptions')
|
->assertSee('Risk exceptions')
|
||||||
->assertSee('Provider Health')
|
->assertSee('Provider Health')
|
||||||
->assertSee('Customer-safe output')
|
->assertSee('Customer-safe output')
|
||||||
|
->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)
|
||||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-has-icon=\"true\"]').length === 4", true)
|
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-has-icon=\"true\"]').length === 4", true)
|
||||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-has-chart=\"true\"]').length === 2", true)
|
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-has-chart=\"true\"]').length === 2", true)
|
||||||
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status-icon\"]'); return rows.length > 0 && rows.length === icons.length; })()", true)
|
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status-icon\"]'); return rows.length > 0 && rows.length === icons.length; })()", true)
|
||||||
->assertScript("(() => { const rows = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); return rows.length > 0 && rows.every((row) => { const interactive = row.getAttribute('data-governance-interactive') === 'true'; return interactive ? row.tagName === 'A' : row.tagName === 'DIV'; }); })()", true)
|
->assertScript("(() => { const rows = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); return rows.length > 0 && rows.every((row) => { const interactive = row.getAttribute('data-governance-interactive') === 'true'; return interactive ? row.tagName === 'A' : row.tagName === 'DIV'; }); })()", true)
|
||||||
->assertScript("(() => { const governance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); const operations = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]')); const rows = [...governance, ...operations]; return rows.length > 0 && rows.every((row) => row.getAttribute('data-overview-row-style') === 'secondary-list-row'); })()", true)
|
->assertScript("(() => { const governance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"]')); return governance.length > 0 && governance.every((row) => row.getAttribute('data-overview-row-style') === 'secondary-list-row'); })()", true)
|
||||||
->assertScript("(() => { const interactiveGovernance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"][data-governance-interactive=\"true\"]')); const operations = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]')); const rows = [...interactiveGovernance, ...operations]; return rows.length > 0 && rows.every((row) => row.className.includes('hover:shadow-md') && row.className.includes('hover:ring-1')); })()", true)
|
->assertScript("(() => { const interactiveGovernance = Array.from(document.querySelectorAll('[data-testid=\"tenant-dashboard-governance-status\"][data-governance-interactive=\"true\"]')); return interactiveGovernance.length === 0 || interactiveGovernance.every((row) => row.className.includes('hover:shadow-md') && row.className.includes('hover:ring-1')); })()", true)
|
||||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"high_severity_findings\"][data-kpi-has-chart=\"true\"]') !== null", true)
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"high_severity_findings\"][data-kpi-has-chart=\"true\"]') !== null", true)
|
||||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"active_operations\"][data-kpi-has-chart=\"true\"]') !== null", true)
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"active_operations\"][data-kpi-has-chart=\"true\"]') !== null", true)
|
||||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"overdue_findings\"][data-kpi-has-chart=\"true\"]') === null", true)
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"overdue_findings\"][data-kpi-has-chart=\"true\"]') === null", true)
|
||||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"missing_permissions\"][data-kpi-has-chart=\"true\"]') === null", true)
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-kpi\"][data-kpi-key=\"missing_permissions\"][data-kpi-has-chart=\"true\"]') === null", true)
|
||||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action\"]').length <= 3", true)
|
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action\"]').length <= 3", true)
|
||||||
->assertScript("(() => { const actions = document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action-icon\"]'); return actions.length === 0 || icons.length === actions.length; })()", true)
|
->assertScript("(() => { const actions = document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-recommended-action-icon\"]'); return actions.length === 0 || icons.length === actions.length; })()", true)
|
||||||
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-recent-operation-icon\"]'); return rows.length === icons.length; })()", true)
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-summary\"]') !== null", true)
|
||||||
|
->assertScript("(() => { const card = document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-summary\"]'); if (! card) return false; return card.className.includes('border-gray-200') && card.className.includes('bg-white') && ! card.className.includes('border-warning-200') && ! card.className.includes('bg-warning-50'); })()", true)
|
||||||
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-secondary-action\"]') !== null", true)
|
||||||
|
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); return rows.length >= 1 && rows.length <= 3; })()", true)
|
||||||
|
->assertScript("(() => { const rows = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); const icons = document.querySelectorAll('[data-testid=\"tenant-dashboard-operations-attention-item-icon\"]'); return rows.length === icons.length; })()", true)
|
||||||
|
->assertScript("(() => { const item = document.querySelector('[data-testid=\"tenant-dashboard-operations-attention-item\"]'); if (! item) return false; return item.className.includes('border-gray-200') && item.className.includes('border-l-4') && item.className.includes('border-l-warning-400') && ! item.className.includes('border-warning-200') && ! item.className.includes('bg-warning-50'); })()", true)
|
||||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-readiness-card\"]').length === 4", true)
|
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-readiness-card\"]').length === 4", true)
|
||||||
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-readiness-card\"][data-readiness-key=\"provider_health\"]') !== null", true)
|
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-readiness-card\"][data-readiness-key=\"provider_health\"]') !== null", true)
|
||||||
->assertScript("! document.body.innerHTML.includes('fixed bottom-4 right-4 z-[999999] w-96 space-y-2')", true)
|
->assertScript("! document.body.innerHTML.includes('fixed bottom-4 right-4 z-[999999] w-96 space-y-2')", true)
|
||||||
->assertScript("(() => { const overview = document.querySelector('[data-testid=\"tenant-dashboard-overview\"]'); const main = document.querySelector('[data-testid=\"tenant-dashboard-overview-main\"]'); if (! overview || ! main) return false; const overviewWidth = overview.getBoundingClientRect().width; const mainWidth = main.getBoundingClientRect().width; return overviewWidth >= 600 && mainWidth >= 400; })()", true)
|
->assertScript("(() => { const overview = document.querySelector('[data-testid=\"tenant-dashboard-overview\"]'); const main = document.querySelector('[data-testid=\"tenant-dashboard-overview-main\"]'); if (! overview || ! main) return false; const overviewWidth = overview.getBoundingClientRect().width; const mainWidth = main.getBoundingClientRect().width; return overviewWidth >= 600 && mainWidth >= 400; })()", true)
|
||||||
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-overview\"] table').length === 0", true)
|
->assertScript("document.querySelectorAll('[data-testid=\"tenant-dashboard-overview\"] table').length === 0", true)
|
||||||
|
->click('Review operation')
|
||||||
|
->waitForText('Show all operations')
|
||||||
|
->assertScript("window.location.pathname.includes('/admin/operations/{$operation->getKey()}')", true)
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertNoConsoleLogs();
|
->assertNoConsoleLogs();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
pest()->browser()->timeout(20_000);
|
||||||
|
|
||||||
|
it('smokes provider-connection detail and managed-environment related provider summary continuity', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'manager',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'name' => 'Spec 281 Browser Environment',
|
||||||
|
'managed_environment_id' => '88888888-8888-8888-8888-888888888888',
|
||||||
|
'status' => ManagedEnvironment::STATUS_ONBOARDING,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'display_name' => 'Spec 281 Browser Connection',
|
||||||
|
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
|
||||||
|
'is_default' => true,
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = TenantOnboardingSession::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
|
||||||
|
'current_step' => 'connection',
|
||||||
|
'state' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'started_by_user_id' => (int) $user->getKey(),
|
||||||
|
'updated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
visit(ProviderConnectionResource::getUrl('view', [
|
||||||
|
'record' => $connection,
|
||||||
|
'managed_environment_id' => $tenant->external_id,
|
||||||
|
], panel: 'admin'))
|
||||||
|
->waitForText('Spec 281 Browser Connection')
|
||||||
|
->assertSee('Target scope')
|
||||||
|
->assertSee('Spec 281 Browser Environment')
|
||||||
|
->assertSee('Provider context')
|
||||||
|
->assertSee('Microsoft tenant ID')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs();
|
||||||
|
|
||||||
|
visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
|
||||||
|
->waitForText('Provider connection')
|
||||||
|
->assertSee('Ready - Spec 281 Browser Environment')
|
||||||
|
->assertSee('Spec 281 Browser Environment')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs();
|
||||||
|
|
||||||
|
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
|
||||||
|
->waitForText('Provider connection')
|
||||||
|
->assertSee('Spec 281 Browser Connection')
|
||||||
|
->assertSee('Target scope')
|
||||||
|
->assertSee('Spec 281 Browser Environment')
|
||||||
|
->assertSee('Open Provider Connections')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs();
|
||||||
|
});
|
||||||
@ -102,7 +102,7 @@
|
|||||||
'provider_connection_id',
|
'provider_connection_id',
|
||||||
'provider',
|
'provider',
|
||||||
'target_scope',
|
'target_scope',
|
||||||
'provider_identity_context',
|
'provider_context',
|
||||||
'connection_type',
|
'connection_type',
|
||||||
])
|
])
|
||||||
->and($metadata)->not->toHaveKey('entra_tenant_id')
|
->and($metadata)->not->toHaveKey('entra_tenant_id')
|
||||||
@ -112,7 +112,10 @@
|
|||||||
'scope_identifier' => '88888888-8888-8888-8888-888888888888',
|
'scope_identifier' => '88888888-8888-8888-8888-888888888888',
|
||||||
'shared_label' => 'Target scope',
|
'shared_label' => 'Target scope',
|
||||||
])
|
])
|
||||||
->and($metadata['provider_identity_context'][0] ?? [])->toMatchArray([
|
->and($metadata['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
])
|
||||||
|
->and($metadata['provider_context']['details'][0] ?? [])->toMatchArray([
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'detail_key' => 'microsoft_tenant_id',
|
'detail_key' => 'microsoft_tenant_id',
|
||||||
'detail_label' => 'Microsoft tenant ID',
|
'detail_label' => 'Microsoft tenant ID',
|
||||||
|
|||||||
@ -12,6 +12,8 @@
|
|||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||||
|
|
||||||
use function Pest\Laravel\mock;
|
use function Pest\Laravel\mock;
|
||||||
@ -89,6 +91,82 @@ function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpr
|
|||||||
->toBe('/admin/tenants/'.urlencode((string) $tenant->external_id).'/required-permissions?source=tenant_dashboard');
|
->toBe('/admin/tenants/'.urlencode((string) $tenant->external_id).'/required-permissions?source=tenant_dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prioritizes operations requiring attention below permissions and high severity findings and keeps canonical hub links', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
mockTenantDashboardActionPermissions([
|
||||||
|
'overall' => 'blocked',
|
||||||
|
'counts' => [
|
||||||
|
'missing_application' => 1,
|
||||||
|
'missing_delegated' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'created_at' => now()->subMinute(),
|
||||||
|
'started_at' => now()->subMinutes(2),
|
||||||
|
'completed_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$summary = app(TenantDashboardSummaryBuilder::class)
|
||||||
|
->build($tenant, $user)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$activeOperationSummary = $summary['activeOperationSummary'] ?? null;
|
||||||
|
$recommendedActions = $summary['recommendedActions'] ?? [];
|
||||||
|
|
||||||
|
expect($activeOperationSummary)
|
||||||
|
->not->toBeNull()
|
||||||
|
->and($activeOperationSummary['items'][0]['primaryActionLabel'] ?? null)->toBe('Review operation')
|
||||||
|
->and($activeOperationSummary['items'][0]['primaryActionUrl'] ?? null)->toBe(OperationRunLinks::view($run, $tenant))
|
||||||
|
->and($activeOperationSummary['secondaryActionLabel'] ?? null)->toBe('Open operations hub')
|
||||||
|
->and($activeOperationSummary['secondaryActionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP))
|
||||||
|
->and(array_column($recommendedActions, 'key'))->toBe([
|
||||||
|
'required_permissions',
|
||||||
|
'high_severity_findings',
|
||||||
|
'operations_requiring_attention',
|
||||||
|
])
|
||||||
|
->and($recommendedActions[2]['title'] ?? null)->toBe('Review operations requiring attention')
|
||||||
|
->and($recommendedActions[2]['reason'] ?? null)->toBe('One or more operations finished with an outcome that needs follow-up.')
|
||||||
|
->and($recommendedActions[2]['impact'] ?? null)->toBe('The tenant should not be treated as fully healthy until the operation outcome has been reviewed.')
|
||||||
|
->and($recommendedActions[2]['actionLabel'] ?? null)->toBe('Review operations')
|
||||||
|
->and($recommendedActions[2]['actionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses review permissions as the top recommended-action CTA when permissions are the highest follow-up', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
mockTenantDashboardActionPermissions([
|
||||||
|
'overall' => 'blocked',
|
||||||
|
'counts' => [
|
||||||
|
'missing_application' => 2,
|
||||||
|
'missing_delegated' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recommendedActions = app(TenantDashboardSummaryBuilder::class)
|
||||||
|
->build($tenant, $user)
|
||||||
|
->toArray()['recommendedActions'];
|
||||||
|
|
||||||
|
expect($recommendedActions[0]['key'] ?? null)->toBe('required_permissions')
|
||||||
|
->and($recommendedActions[0]['title'] ?? null)->toBe('Review permissions')
|
||||||
|
->and($recommendedActions[0]['actionLabel'] ?? null)->toBe('Review permissions')
|
||||||
|
->and($recommendedActions[0]['actionUrl'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant));
|
||||||
|
});
|
||||||
|
|
||||||
it('orders productized recommended actions by priority and caps the visible list at three repo-real CTAs', function (): void {
|
it('orders productized recommended actions by priority and caps the visible list at three repo-real CTAs', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -197,6 +198,37 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives the primary header CTA from the top recommended action instead of hard-coding operations copy', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
mockTenantDashboardAuthorizationPermissions([
|
||||||
|
'overall' => 'blocked',
|
||||||
|
'counts' => [
|
||||||
|
'missing_application' => 1,
|
||||||
|
'missing_delegated' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
$summary = app(TenantDashboardSummaryBuilder::class)
|
||||||
|
->build($tenant, $user)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(TenantDashboard::class)
|
||||||
|
->assertActionVisible('primaryFollowUp');
|
||||||
|
|
||||||
|
$primaryAction = collect(tenantDashboardProductizationHeaderActions($component))
|
||||||
|
->first(static fn ($action): bool => $action instanceof Action && $action->getName() === 'primaryFollowUp');
|
||||||
|
|
||||||
|
expect($summary['recommendedActions'][0]['actionLabel'] ?? null)->toBe('Review permissions')
|
||||||
|
->and($summary['recommendedActions'][0]['actionUrl'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||||
|
->and($primaryAction)->toBeInstanceOf(Action::class)
|
||||||
|
->and($primaryAction->getLabel())->toBe('Review permissions')
|
||||||
|
->and($primaryAction->getUrl())->toBe(RequiredPermissionsLinks::requiredPermissions($tenant));
|
||||||
|
});
|
||||||
|
|
||||||
it('renders governance status rows as interactive only when a repo-real follow-up url is available', function (): void {
|
it('renders governance status rows as interactive only when a repo-real follow-up url is available', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||||
@ -78,25 +79,29 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
->assertSee($tenant->name)
|
->assertSee($tenant->name)
|
||||||
->assertSee('Recommended next actions')
|
->assertSee('Recommended next actions')
|
||||||
->assertSee('Governance status')
|
->assertSee('Governance status')
|
||||||
|
->assertSee('Operations needing attention')
|
||||||
->assertSee('Current review')
|
->assertSee('Current review')
|
||||||
->assertSee('Risk exceptions')
|
->assertSee('Risk exceptions')
|
||||||
->assertSee('Provider Health')
|
->assertSee('Provider Health')
|
||||||
->assertSee('Customer-safe output')
|
->assertSee('Customer-safe output')
|
||||||
->assertSee('Recent operations');
|
->assertSee('Operations requiring attention')
|
||||||
|
->assertSee('Review operation')
|
||||||
|
->assertSee('Open operations hub')
|
||||||
|
->assertDontSee('Recent operations');
|
||||||
|
|
||||||
$content = $response->getContent();
|
$content = $response->getContent();
|
||||||
$contextChipsPosition = strpos($content, 'data-testid="tenant-dashboard-context-chips"');
|
$contextChipsPosition = strpos($content, 'data-testid="tenant-dashboard-context-chips"');
|
||||||
$firstKpiPosition = strpos($content, 'data-testid="tenant-dashboard-kpi"');
|
$firstKpiPosition = strpos($content, 'data-testid="tenant-dashboard-kpi"');
|
||||||
$governanceStatusCount = substr_count($content, 'data-testid="tenant-dashboard-governance-status"');
|
$governanceStatusCount = substr_count($content, 'data-testid="tenant-dashboard-governance-status"');
|
||||||
$recentOperationCount = substr_count($content, 'data-testid="tenant-dashboard-recent-operation"');
|
|
||||||
$secondaryListRowCount = substr_count($content, 'data-overview-row-style="secondary-list-row"');
|
$secondaryListRowCount = substr_count($content, 'data-overview-row-style="secondary-list-row"');
|
||||||
|
|
||||||
expect(substr_count($content, 'data-testid="tenant-dashboard-kpi"'))->toBe(4)
|
expect(substr_count($content, 'data-testid="tenant-dashboard-kpi"'))->toBe(4)
|
||||||
->and($content)->toContain('data-testid="tenant-dashboard-posture-pill"')
|
->and($content)->toContain('data-testid="tenant-dashboard-posture-pill"')
|
||||||
->and($content)->toContain('data-testid="tenant-dashboard-context-chips"')
|
->and($content)->toContain('data-testid="tenant-dashboard-context-chips"')
|
||||||
->and($content)->toContain('lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:items-center')
|
->and($content)->toContain('class="flex w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-start md:flex-nowrap"')
|
||||||
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace"')
|
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace"')
|
||||||
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full items-center')
|
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full max-w-full items-center')
|
||||||
|
->and($content)->toContain('sm:w-auto sm:max-w-[20rem] lg:max-w-[24rem]')
|
||||||
->and($content)->toContain('Workspace: '.$tenant->workspace->name)
|
->and($content)->toContain('Workspace: '.$tenant->workspace->name)
|
||||||
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-provider"')
|
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-provider"')
|
||||||
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-provider-microsoft-logo"')
|
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-provider-microsoft-logo"')
|
||||||
@ -108,7 +113,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
->and($contextChipsPosition)->not->toBeFalse()
|
->and($contextChipsPosition)->not->toBeFalse()
|
||||||
->and($firstKpiPosition)->not->toBeFalse()
|
->and($firstKpiPosition)->not->toBeFalse()
|
||||||
->and($contextChipsPosition)->toBeLessThan($firstKpiPosition)
|
->and($contextChipsPosition)->toBeLessThan($firstKpiPosition)
|
||||||
->and($secondaryListRowCount)->toBe($governanceStatusCount + $recentOperationCount)
|
->and($secondaryListRowCount)->toBe($governanceStatusCount)
|
||||||
->and($content)->toContain('hover:shadow-md')
|
->and($content)->toContain('hover:shadow-md')
|
||||||
->and($content)->toContain('hover:ring-1')
|
->and($content)->toContain('hover:ring-1')
|
||||||
->and(substr_count($content, 'data-kpi-has-icon="true"'))->toBe(4)
|
->and(substr_count($content, 'data-kpi-has-icon="true"'))->toBe(4)
|
||||||
@ -116,12 +121,13 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
->and(substr_count($content, 'data-testid="tenant-dashboard-recommended-action"'))->toBeLessThanOrEqual(3)
|
->and(substr_count($content, 'data-testid="tenant-dashboard-recommended-action"'))->toBeLessThanOrEqual(3)
|
||||||
->and(substr_count($content, 'tenant-dashboard-recommended-actions'))->toBeGreaterThanOrEqual(1)
|
->and(substr_count($content, 'tenant-dashboard-recommended-actions'))->toBeGreaterThanOrEqual(1)
|
||||||
->and(substr_count($content, 'data-testid="tenant-dashboard-governance-status-icon"'))->toBe(substr_count($content, 'data-testid="tenant-dashboard-governance-status"'))
|
->and(substr_count($content, 'data-testid="tenant-dashboard-governance-status-icon"'))->toBe(substr_count($content, 'data-testid="tenant-dashboard-governance-status"'))
|
||||||
->and(substr_count($content, 'data-testid="tenant-dashboard-recent-operation-icon"'))->toBe(substr_count($content, 'data-testid="tenant-dashboard-recent-operation"'))
|
->and(substr_count($content, 'data-testid="tenant-dashboard-operations-attention-item-icon"'))->toBeGreaterThanOrEqual(1)
|
||||||
->and(substr_count($content, 'data-testid="tenant-dashboard-readiness-card"'))->toBe(4)
|
->and(substr_count($content, 'data-testid="tenant-dashboard-readiness-card"'))->toBe(4)
|
||||||
->and($content)->toContain('data-readiness-key="provider_health"')
|
->and($content)->toContain('data-readiness-key="provider_health"')
|
||||||
->and($content)->not->toContain('Open customer workspace')
|
->and($content)->not->toContain('Open customer workspace')
|
||||||
->and($content)->not->toContain('fixed bottom-4 right-4 z-[999999] w-96 space-y-2')
|
->and($content)->not->toContain('fixed bottom-4 right-4 z-[999999] w-96 space-y-2')
|
||||||
->and($content)->toContain('High severity findings');
|
->and($content)->toContain('High severity findings')
|
||||||
|
->and($content)->not->toContain('section_recent_operations');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds repo-real icon metadata and only supported sparkline series to tenant dashboard kpis', function (): void {
|
it('adds repo-real icon metadata and only supported sparkline series to tenant dashboard kpis', function (): void {
|
||||||
@ -201,6 +207,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
'missing_permissions',
|
'missing_permissions',
|
||||||
'active_operations',
|
'active_operations',
|
||||||
])
|
])
|
||||||
|
->and($kpis['active_operations']['label'])->toBe('Operations needing attention')
|
||||||
->and($kpis->pluck('icon')->filter()->count())->toBe(4)
|
->and($kpis->pluck('icon')->filter()->count())->toBe(4)
|
||||||
->and($kpis['high_severity_findings']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
->and($kpis['high_severity_findings']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
||||||
->and($kpis['high_severity_findings']['description'])->toBe('4 active · 4 new in 7d')
|
->and($kpis['high_severity_findings']['description'])->toBe('4 active · 4 new in 7d')
|
||||||
@ -210,7 +217,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
->and($kpis['missing_permissions']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
->and($kpis['missing_permissions']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
||||||
->and($kpis['missing_permissions']['description'])->toBe('2 app · 1 delegated missing')
|
->and($kpis['missing_permissions']['description'])->toBe('2 app · 1 delegated missing')
|
||||||
->and($kpis['active_operations']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
->and($kpis['active_operations']['icon'])->toBe('heroicon-m-arrow-trending-up')
|
||||||
->and($kpis['active_operations']['description'])->toBe('3 need follow-up · 3 in 7d')
|
->and($kpis['active_operations']['description'])->toBe('3 operations require attention')
|
||||||
->and($kpis['active_operations']['chart'])->toBe([0, 1, 0, 0, 2, 0, 0])
|
->and($kpis['active_operations']['chart'])->toBe([0, 1, 0, 0, 2, 0, 0])
|
||||||
->and($kpis['overdue_findings']['chart'])->toBeNull()
|
->and($kpis['overdue_findings']['chart'])->toBeNull()
|
||||||
->and($kpis['missing_permissions']['chart'])->toBeNull();
|
->and($kpis['missing_permissions']['chart'])->toBeNull();
|
||||||
@ -219,7 +226,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds semantic icon metadata to governance status rows and repo-real recent operation types', function (): void {
|
it('adds semantic icon metadata to governance status rows and curated operations attention items', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
mockTenantDashboardSummaryPermissions();
|
mockTenantDashboardSummaryPermissions();
|
||||||
@ -229,7 +236,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory_sync',
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
'created_at' => now()->subMinutes(3),
|
'created_at' => now()->subMinutes(3),
|
||||||
'completed_at' => now()->subMinutes(3),
|
'completed_at' => now()->subMinutes(3),
|
||||||
]);
|
]);
|
||||||
@ -239,7 +246,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => 'tenant.review_pack.generate',
|
'type' => 'tenant.review_pack.generate',
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
'created_at' => now()->subMinutes(2),
|
'created_at' => now()->subMinutes(2),
|
||||||
'completed_at' => now()->subMinutes(2),
|
'completed_at' => now()->subMinutes(2),
|
||||||
]);
|
]);
|
||||||
@ -249,7 +256,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK,
|
'type' => OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK,
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
'outcome' => OperationRunOutcome::Blocked->value,
|
||||||
'created_at' => now()->subMinute(),
|
'created_at' => now()->subMinute(),
|
||||||
'completed_at' => now()->subMinute(),
|
'completed_at' => now()->subMinute(),
|
||||||
]);
|
]);
|
||||||
@ -259,16 +266,16 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$governanceStatus = collect($summary['governanceStatus'])->keyBy('key');
|
$governanceStatus = collect($summary['governanceStatus'])->keyBy('key');
|
||||||
$recentOperations = collect($summary['recentOperations'])->keyBy('type');
|
$attentionOperations = collect($summary['activeOperationSummary']['items'] ?? [])->keyBy('type');
|
||||||
|
|
||||||
expect($governanceStatus['baseline_compare']['icon'] ?? null)->toBe('heroicon-m-arrows-right-left')
|
expect($governanceStatus['baseline_compare']['icon'] ?? null)->toBe('heroicon-m-arrows-right-left')
|
||||||
->and($governanceStatus['evidence_coverage']['icon'] ?? null)->toBe('heroicon-m-document-check')
|
->and($governanceStatus['evidence_coverage']['icon'] ?? null)->toBe('heroicon-m-document-check')
|
||||||
->and($governanceStatus['review_freshness']['icon'] ?? null)->toBe('heroicon-m-clipboard-document-check')
|
->and($governanceStatus['review_freshness']['icon'] ?? null)->toBe('heroicon-m-clipboard-document-check')
|
||||||
->and($governanceStatus['provider_permissions']['icon'] ?? null)->toBe('heroicon-m-key')
|
->and($governanceStatus['provider_permissions']['icon'] ?? null)->toBe('heroicon-m-key')
|
||||||
->and($governanceStatus['backup_posture']['icon'] ?? null)->toBe('heroicon-m-archive-box')
|
->and($governanceStatus['backup_posture']['icon'] ?? null)->toBe('heroicon-m-archive-box')
|
||||||
->and($recentOperations['Inventory sync']['icon'] ?? null)->toBe('heroicon-m-arrow-path')
|
->and($attentionOperations['Inventory sync']['icon'] ?? null)->toBe('heroicon-m-arrow-path')
|
||||||
->and($recentOperations['Review pack generation']['icon'] ?? null)->toBe('heroicon-m-document-arrow-down')
|
->and($attentionOperations['Review pack generation']['icon'] ?? null)->toBe('heroicon-m-document-arrow-down')
|
||||||
->and($recentOperations['Permission posture check']['icon'] ?? null)->toBe('heroicon-m-key');
|
->and($attentionOperations['Permission posture check']['icon'] ?? null)->toBe('heroicon-m-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows calm honest fallbacks when no urgent tenant follow-up is visible', function (): void {
|
it('shows calm honest fallbacks when no urgent tenant follow-up is visible', function (): void {
|
||||||
@ -290,14 +297,130 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
|
|||||||
$response = $this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
$response = $this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('No immediate action is waiting.')
|
->assertSee('No immediate action is waiting.')
|
||||||
->assertSee('Recent operations');
|
->assertDontSee('Recent operations')
|
||||||
|
->assertDontSee('Operations requiring attention');
|
||||||
|
|
||||||
$content = $response->getContent();
|
$content = $response->getContent();
|
||||||
|
|
||||||
$recentOperationCount = substr_count($content, 'data-testid="tenant-dashboard-recent-operation"');
|
|
||||||
|
|
||||||
expect(substr_count($content, 'data-testid="tenant-dashboard-recommended-actions-empty"'))->toBe(1)
|
expect(substr_count($content, 'data-testid="tenant-dashboard-recommended-actions-empty"'))->toBe(1)
|
||||||
->and($recentOperationCount)->toBeGreaterThan(0)
|
->and($content)->not->toContain('data-testid="tenant-dashboard-operations-attention-summary"')
|
||||||
->and($recentOperationCount)->toBeLessThanOrEqual(4)
|
|
||||||
->and($content)->not->toContain('data-testid="tenant-dashboard-recent-operations-empty"');
|
->and($content)->not->toContain('data-testid="tenant-dashboard-recent-operations-empty"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds a curated operations requiring attention summary and excludes healthy active runs', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
mockTenantDashboardSummaryPermissions();
|
||||||
|
|
||||||
|
$healthyRunningRun = OperationRun::factory()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Running->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subMinute(),
|
||||||
|
'started_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$followUpRun = OperationRun::factory()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'created_at' => now()->subMinutes(6),
|
||||||
|
'started_at' => now()->subMinutes(5),
|
||||||
|
'completed_at' => now()->subMinutes(4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$blockedRun = OperationRun::factory()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Blocked->value,
|
||||||
|
'created_at' => now()->subMinutes(5),
|
||||||
|
'started_at' => now()->subMinutes(4),
|
||||||
|
'completed_at' => now()->subMinutes(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$summary = app(TenantDashboardSummaryBuilder::class)
|
||||||
|
->build($tenant, $user)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$activeOperationSummary = $summary['activeOperationSummary'] ?? null;
|
||||||
|
$items = collect($activeOperationSummary['items'] ?? []);
|
||||||
|
|
||||||
|
expect($activeOperationSummary)
|
||||||
|
->not->toBeNull()
|
||||||
|
->and($activeOperationSummary['title'] ?? null)->toBe('Operations requiring attention')
|
||||||
|
->and($activeOperationSummary['count'] ?? null)->toBe(2)
|
||||||
|
->and($activeOperationSummary['secondaryActionLabel'] ?? null)->toBe('Open operations hub')
|
||||||
|
->and($activeOperationSummary['secondaryActionUrl'] ?? null)->toBe(OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
))
|
||||||
|
->and($items)->toHaveCount(2)
|
||||||
|
->and($items->pluck('id')->all())->toBe([
|
||||||
|
(int) $blockedRun->getKey(),
|
||||||
|
(int) $followUpRun->getKey(),
|
||||||
|
])
|
||||||
|
->and($items->pluck('primaryActionLabel')->unique()->all())->toBe(['Review operation'])
|
||||||
|
->and($items->pluck('primaryActionUrl')->all())->toBe([
|
||||||
|
OperationRunLinks::view($blockedRun, $tenant),
|
||||||
|
OperationRunLinks::view($followUpRun, $tenant),
|
||||||
|
])
|
||||||
|
->and($items->pluck('attentionLabel')->unique()->all())->toBe(['Follow-up required'])
|
||||||
|
->and($items->pluck('timingLabel')->filter()->isNotEmpty())->toBeTrue()
|
||||||
|
->and($items->pluck('outcomeSentence')->filter()->isNotEmpty())->toBeTrue()
|
||||||
|
->and($items->pluck('reason')->filter()->isNotEmpty())->toBeTrue()
|
||||||
|
->and($items->pluck('impact')->filter()->isNotEmpty())->toBeTrue()
|
||||||
|
->and($items->pluck('id')->contains((int) $healthyRunningRun->getKey()))->toBeFalse();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
$this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('data-testid="tenant-dashboard-operations-attention-summary"', false)
|
||||||
|
->assertSee('Review operation')
|
||||||
|
->assertSee('Open operations hub')
|
||||||
|
->assertSee('Completed '.$followUpRun->completed_at?->diffForHumans())
|
||||||
|
->assertSee('Inventory sync')
|
||||||
|
->assertDontSee('Operation #'.$followUpRun->getKey())
|
||||||
|
->assertDontSee('Recent operations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the compact active operations summary when no qualifying visible run exists', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
mockTenantDashboardSummaryPermissions();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'created_at' => now()->subMinutes(3),
|
||||||
|
'started_at' => now()->subMinutes(2),
|
||||||
|
'completed_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$summary = app(TenantDashboardSummaryBuilder::class)
|
||||||
|
->build($tenant, $user)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
expect($summary['activeOperationSummary'] ?? null)->toBeNull();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
$this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertDontSee('data-testid="tenant-dashboard-operations-attention-summary"', false)
|
||||||
|
->assertDontSee('Review operation')
|
||||||
|
->assertDontSee('Open operations hub')
|
||||||
|
->assertDontSee('Recent operations');
|
||||||
|
});
|
||||||
|
|||||||
@ -247,7 +247,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
|||||||
'High severity findings',
|
'High severity findings',
|
||||||
'Overdue findings',
|
'Overdue findings',
|
||||||
'Missing permissions',
|
'Missing permissions',
|
||||||
'Active operations',
|
'Operations needing attention',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($stats['High severity findings'])->toMatchArray([
|
expect($stats['High severity findings'])->toMatchArray([
|
||||||
@ -263,7 +263,7 @@ function makeHealthyBackupForRecoveryKpi(\App\Models\ManagedEnvironment $tenant,
|
|||||||
], panel: 'tenant', tenant: $tenant))
|
], panel: 'tenant', tenant: $tenant))
|
||||||
->and((int) $stats['Missing permissions']['value'])->toBeGreaterThan(0)
|
->and((int) $stats['Missing permissions']['value'])->toBeGreaterThan(0)
|
||||||
->and($stats['Missing permissions']['url'])->not->toBeNull()
|
->and($stats['Missing permissions']['url'])->not->toBeNull()
|
||||||
->and($stats['Active operations'])->toMatchArray([
|
->and($stats['Operations needing attention'])->toMatchArray([
|
||||||
'value' => '3',
|
'value' => '3',
|
||||||
'url' => OperationRunLinks::index(
|
'url' => OperationRunLinks::index(
|
||||||
$tenant,
|
$tenant,
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('excludes closed workspaces from the workspace chooser', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$openWorkspace = Workspace::factory()->create(['name' => 'Open Workspace']);
|
||||||
|
$closedWorkspace = Workspace::factory()->create([
|
||||||
|
'name' => 'Closed Workspace',
|
||||||
|
'closed_at' => now(),
|
||||||
|
'closed_reason' => 'No longer active.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $openWorkspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $closedWorkspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('filament.admin.pages.choose-workspace'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Open Workspace')
|
||||||
|
->assertDontSee('Closed Workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears closed remembered workspace context and routes to explicit recovery', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$openWorkspace = Workspace::factory()->create();
|
||||||
|
$closedWorkspace = Workspace::factory()->create([
|
||||||
|
'closed_at' => now(),
|
||||||
|
'closed_reason' => 'The workspace was closed by support.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $openWorkspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $closedWorkspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->forceFill(['last_workspace_id' => (int) $closedWorkspace->getKey()])->save();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $closedWorkspace->getKey()])
|
||||||
|
->get('/admin/_test/workspace-context')
|
||||||
|
->assertRedirect('/admin/choose-workspace')
|
||||||
|
->assertSessionMissing(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
expect($user->fresh()->last_workspace_id)->toBeNull();
|
||||||
|
});
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('keeps provider connection list and detail surfaces centered on target scope', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => 'Spec 281 visible connection',
|
||||||
|
'entra_tenant_id' => '66666666-6666-6666-6666-666666666666',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(ListProviderConnections::class);
|
||||||
|
$table = $component->instance()->getTable();
|
||||||
|
$visibleColumnNames = collect($table->getVisibleColumns())
|
||||||
|
->map(fn ($column): string => $column->getName())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$globalSearchProperty = new ReflectionProperty(ProviderConnectionResource::class, 'isGloballySearchable');
|
||||||
|
$globalSearchProperty->setAccessible(true);
|
||||||
|
|
||||||
|
expect($globalSearchProperty->getValue())->toBeFalse()
|
||||||
|
->and(array_keys(ProviderConnectionResource::getPages()))->toContain('view', 'edit')
|
||||||
|
->and($visibleColumnNames)->toContain('provider', 'target_scope', 'consent_status', 'verification_status')
|
||||||
|
->and($visibleColumnNames)->not->toContain('entra_tenant_id');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(ProviderConnectionResource::getUrl('view', [
|
||||||
|
'record' => $connection,
|
||||||
|
'managed_environment_id' => $tenant->external_id,
|
||||||
|
], panel: 'admin'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Target scope')
|
||||||
|
->assertSee('Provider context')
|
||||||
|
->assertSee('Microsoft tenant ID')
|
||||||
|
->assertDontSee('Entra tenant ID');
|
||||||
|
});
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes and restores a tenant from workspace context without deleting history', function (): void {
|
||||||
|
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||||
|
'name' => 'Removal ManagedEnvironment',
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('remove_from_workspace')
|
||||||
|
->assertActionExists('remove_from_workspace', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->callAction('remove_from_workspace', data: [
|
||||||
|
'removal_reason' => 'Tenant was removed from active workspace operations.',
|
||||||
|
])
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Tenant removed from workspace');
|
||||||
|
|
||||||
|
$tenant->refresh();
|
||||||
|
|
||||||
|
expect($tenant->isRemovedFromWorkspace())->toBeTrue()
|
||||||
|
->and($tenant->is_current)->toBeFalse()
|
||||||
|
->and($tenant->workspaceRemovalReason())->toBe('Tenant was removed from active workspace operations.')
|
||||||
|
->and($tenant->isSelectableAsContext())->toBeFalse()
|
||||||
|
->and(DB::table('managed_environment_memberships')
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
|
||||||
|
$removeAudit = AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('action', AuditActionId::TenantRemovedFromWorkspace->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($removeAudit)->not->toBeNull()
|
||||||
|
->and($removeAudit?->metadata['reason'] ?? null)->toBe('Tenant was removed from active workspace operations.')
|
||||||
|
->and($removeAudit?->metadata['after_status'] ?? null)->toBe('removed_from_workspace');
|
||||||
|
|
||||||
|
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant->fresh());
|
||||||
|
|
||||||
|
expect($decision['is_blocked'])->toBeTrue()
|
||||||
|
->and($decision['reason_family'])->toBe(WorkspaceCommercialLifecycleResolver::REASON_FAMILY_TENANT_WORKSPACE_REMOVAL);
|
||||||
|
|
||||||
|
expect(fn () => app(OperationRunService::class)->ensureRun(
|
||||||
|
tenant: $tenant->fresh(),
|
||||||
|
type: OperationRunType::InventorySync->value,
|
||||||
|
inputs: ['scope' => 'tenant_removal_test'],
|
||||||
|
initiator: $user,
|
||||||
|
))->toThrow(InvalidArgumentException::class, 'removed from the workspace');
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Removed from workspace');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/t/'.$tenant->external_id)
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('restore_to_workspace')
|
||||||
|
->assertActionExists('restore_to_workspace', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->callAction('restore_to_workspace', data: [
|
||||||
|
'restore_reason' => 'Tenant is approved for active workspace operations again.',
|
||||||
|
])
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Tenant restored to workspace');
|
||||||
|
|
||||||
|
$tenant->refresh();
|
||||||
|
|
||||||
|
expect($tenant->isRemovedFromWorkspace())->toBeFalse()
|
||||||
|
->and($tenant->workspaceRemovalReason())->toBeNull()
|
||||||
|
->and($tenant->isSelectableAsContext())->toBeTrue()
|
||||||
|
->and(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('action', AuditActionId::TenantRestoredToWorkspace->value)
|
||||||
|
->where('metadata->reason', 'Tenant is approved for active workspace operations again.')
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a closed workspace as read-only in the admin workspace detail surface', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create([
|
||||||
|
'name' => 'Closed Admin Workspace',
|
||||||
|
'closed_at' => now(),
|
||||||
|
'closed_reason' => 'Closed after customer offboarding.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(WorkspaceResource::getUrl('view', ['record' => $workspace], panel: 'admin'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Closed')
|
||||||
|
->assertSee('Closed after customer offboarding.')
|
||||||
|
->assertDontSee('Suspended read-only');
|
||||||
|
|
||||||
|
expect(WorkspaceResource::canEdit($workspace))->toBeFalse();
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewWorkspace::class, ['record' => $workspace->getRouteKey()])
|
||||||
|
->assertActionHidden('edit');
|
||||||
|
});
|
||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||||
use App\Filament\Widgets\Dashboard\RecentOperations as DashboardRecentOperations;
|
|
||||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
@ -25,7 +24,7 @@
|
|||||||
'status' => Finding::STATUS_NEW,
|
'status' => Finding::STATUS_NEW,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$operation = OperationRun::factory()->create([
|
OperationRun::factory()->create([
|
||||||
'managed_environment_id' => $tenant->getKey(),
|
'managed_environment_id' => $tenant->getKey(),
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory_sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
@ -53,26 +52,22 @@
|
|||||||
Bus::fake();
|
Bus::fake();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($operation, $tenant): void {
|
assertNoOutboundHttp(function () use ($tenant): void {
|
||||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('/admin/choose-workspace', false);
|
->assertSee('/admin/choose-workspace', false)
|
||||||
// NeedsAttention, RecentOperations and RecentDriftFindings are
|
->assertDontSee('data-testid="tenant-dashboard-operations-attention-summary"', false)
|
||||||
// lazy-loaded widgets and will not appear in the initial
|
->assertDontSee('Review operation')
|
||||||
// server-rendered HTML.
|
->assertDontSee('Open operations hub')
|
||||||
|
->assertDontSee('Recent operations');
|
||||||
|
|
||||||
Livewire::test(RecoveryReadiness::class)
|
Livewire::test(RecoveryReadiness::class)
|
||||||
->assertSee('Backup posture')
|
->assertSee('Backup posture')
|
||||||
->assertSee('Healthy');
|
->assertSee('Healthy');
|
||||||
|
|
||||||
Livewire::test(DashboardKpis::class)
|
Livewire::test(DashboardKpis::class)
|
||||||
->assertSee('Active operations')
|
->assertSee('Operations needing attention')
|
||||||
->assertSee('No follow-up queued');
|
->assertSee('No operations need attention');
|
||||||
|
|
||||||
Livewire::test(DashboardRecentOperations::class)
|
|
||||||
->assertSee('Operation ID')
|
|
||||||
->assertSee('Operation #'.$operation->getKey())
|
|
||||||
->assertSee('Inventory sync');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Bus::assertNothingDispatched();
|
Bus::assertNothingDispatched();
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('keeps Microsoft-shaped provider scope out of shared provider platform-core contracts', function (): void {
|
||||||
|
$root = base_path();
|
||||||
|
|
||||||
|
$forbiddenByPath = [
|
||||||
|
'app/Services/Providers/ProviderIdentityResolution.php' => [
|
||||||
|
'tenantContext',
|
||||||
|
'target_scope.entra_tenant_id',
|
||||||
|
],
|
||||||
|
'app/Services/Providers/ProviderIdentityResolver.php' => [
|
||||||
|
'tenantContext',
|
||||||
|
],
|
||||||
|
'app/Services/Providers/PlatformProviderIdentityResolver.php' => [
|
||||||
|
'tenantContext',
|
||||||
|
],
|
||||||
|
'app/Services/Providers/ProviderOperationStartGate.php' => [
|
||||||
|
"'entra_tenant_id' =>",
|
||||||
|
'target_scope.entra_tenant_id',
|
||||||
|
],
|
||||||
|
'config/provider_boundaries.php' => [
|
||||||
|
'target_scope.entra_tenant_id',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($forbiddenByPath as $relativePath => $fragments) {
|
||||||
|
$contents = (string) file_get_contents($root.'/'.$relativePath);
|
||||||
|
|
||||||
|
foreach ($fragments as $fragment) {
|
||||||
|
expect($contents)
|
||||||
|
->not->toContain($fragment, sprintf('%s still exposes [%s] as shared provider-scope truth.', $relativePath, $fragment));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('reuses the provider connection surface summary in onboarding readiness', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'display_name' => 'Spec 281 onboarding connection',
|
||||||
|
'entra_tenant_id' => '77777777-7777-7777-7777-777777777777',
|
||||||
|
'is_default' => true,
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = TenantOnboardingSession::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'entra_tenant_id' => '77777777-7777-7777-7777-777777777777',
|
||||||
|
'current_step' => 'connection',
|
||||||
|
'state' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'started_by_user_id' => (int) $user->getKey(),
|
||||||
|
'updated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()]);
|
||||||
|
|
||||||
|
$method = new ReflectionMethod($component->instance(), 'readinessProviderSummary');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$summary = $method->invoke($component->instance(), $connection->fresh(['tenant']));
|
||||||
|
|
||||||
|
expect($summary['provider'])->toBe('microsoft')
|
||||||
|
->and($summary['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'scope_identifier' => '77777777-7777-7777-7777-777777777777',
|
||||||
|
'shared_label' => 'Target scope',
|
||||||
|
])
|
||||||
|
->and($summary['target_scope_summary'] ?? null)->toBe($tenant->name.' (77777777-7777-7777-7777-777777777777)')
|
||||||
|
->and($summary['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
])
|
||||||
|
->and($summary['target_scope'])->not->toHaveKey('entra_tenant_id')
|
||||||
|
->and($summary)->not->toHaveKey('contextual_identity_details');
|
||||||
|
});
|
||||||
@ -109,9 +109,15 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'compliance',
|
'module' => 'compliance',
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'target_scope' => [
|
'connection_type' => 'platform',
|
||||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
|
||||||
'entra_tenant_name' => 'Contoso',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($run->context['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
]);
|
||||||
|
expect($run->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => $connection->entra_tenant_id,
|
||||||
|
'scope_display_name' => 'Contoso',
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -105,12 +105,17 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
expect($run->outcome)->toBe('succeeded');
|
expect($run->outcome)->toBe('succeeded');
|
||||||
expect($run->context)->toMatchArray([
|
expect($run->context)->toMatchArray([
|
||||||
'target_scope' => [
|
'connection_type' => 'dedicated',
|
||||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
|
||||||
'entra_tenant_name' => 'Contoso',
|
|
||||||
'connection_type' => 'dedicated',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($run->context['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
]);
|
||||||
|
expect($run->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => $connection->entra_tenant_id,
|
||||||
|
'scope_display_name' => 'Contoso',
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
|
|
||||||
expect($connection->metadata)->toMatchArray([
|
expect($connection->metadata)->toMatchArray([
|
||||||
'entra_tenant_name' => 'Contoso',
|
'entra_tenant_name' => 'Contoso',
|
||||||
|
|||||||
@ -52,10 +52,15 @@
|
|||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'health_check',
|
'module' => 'health_check',
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($opRun?->context['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
]);
|
||||||
|
expect($opRun?->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => $connection->entra_tenant_id,
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
|
|
||||||
$notifications = session('filament.notifications', []);
|
$notifications = session('filament.notifications', []);
|
||||||
expect($notifications)->not->toBeEmpty();
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
|||||||
@ -52,10 +52,15 @@
|
|||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'inventory',
|
'module' => 'inventory',
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($opRun?->context['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
]);
|
||||||
|
expect($opRun?->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => $connection->entra_tenant_id,
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('managed_environment_id', $tenant->getKey())
|
->where('managed_environment_id', $tenant->getKey())
|
||||||
@ -105,10 +110,15 @@
|
|||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'inventory',
|
'module' => 'inventory',
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($opRun?->context['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
]);
|
||||||
|
expect($opRun?->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => $connection->entra_tenant_id,
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
|
|
||||||
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
|
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
|
||||||
});
|
});
|
||||||
@ -153,10 +163,15 @@
|
|||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'compliance',
|
'module' => 'compliance',
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($opRun?->context['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
]);
|
||||||
|
expect($opRun?->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => $connection->entra_tenant_id,
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('managed_environment_id', $tenant->getKey())
|
->where('managed_environment_id', $tenant->getKey())
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('resolves provider connections and summaries through one neutral target-scope contract', function (): void {
|
||||||
|
config()->set('graph.client_id', 'platform-client-id');
|
||||||
|
config()->set('graph.client_secret', 'platform-client-secret');
|
||||||
|
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->platform()
|
||||||
|
->consentGranted()
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'display_name' => 'Spec 281 provider connection',
|
||||||
|
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||||
|
'is_default' => true,
|
||||||
|
'is_enabled' => true,
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolution = app(ProviderConnectionResolver::class)
|
||||||
|
->validateConnection($tenant, 'microsoft', $connection->fresh(['tenant']));
|
||||||
|
$summary = ProviderConnectionSurfaceSummary::forConnection($connection->fresh(['tenant']));
|
||||||
|
$summaryPayload = $summary->toArray();
|
||||||
|
|
||||||
|
expect($resolution->resolved)->toBeTrue()
|
||||||
|
->and($resolution->targetScope?->toArray())->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => '11111111-1111-1111-1111-111111111111',
|
||||||
|
'shared_label' => 'Target scope',
|
||||||
|
])
|
||||||
|
->and($resolution->targetScope?->toArray())->not->toHaveKey('entra_tenant_id')
|
||||||
|
->and($summaryPayload['provider'])->toBe('microsoft')
|
||||||
|
->and($summaryPayload['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'scope_identifier' => '11111111-1111-1111-1111-111111111111',
|
||||||
|
])
|
||||||
|
->and($summaryPayload['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
])
|
||||||
|
->and($summaryPayload['target_scope'])->not->toHaveKey('entra_tenant_id')
|
||||||
|
->and($summaryPayload['provider_context']['details'][0] ?? [])->toMatchArray([
|
||||||
|
'detail_key' => 'microsoft_tenant_id',
|
||||||
|
'detail_value' => '11111111-1111-1111-1111-111111111111',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Services\Providers\ProviderIdentityResolver;
|
||||||
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('exposes effective client identity and provider context without shared tenantContext naming', function (): void {
|
||||||
|
config()->set('graph.client_id', 'platform-client-id');
|
||||||
|
config()->set('graph.client_secret', 'platform-client-secret');
|
||||||
|
config()->set('graph.managed_environment_id', 'platform-home-tenant-id');
|
||||||
|
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant']));
|
||||||
|
$providerContextDetails = collect($resolution->providerContext()['details']);
|
||||||
|
|
||||||
|
expect($resolution->resolved)->toBeTrue()
|
||||||
|
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
|
||||||
|
->and(property_exists($resolution, 'tenantContext'))->toBeFalse()
|
||||||
|
->and($resolution->targetScopeIdentifier())->toBe('22222222-2222-2222-2222-222222222222')
|
||||||
|
->and($resolution->effectiveClientIdentity())->toBe([
|
||||||
|
'client_id' => 'platform-client-id',
|
||||||
|
'credential_source' => 'platform_config',
|
||||||
|
])
|
||||||
|
->and($providerContextDetails->contains(
|
||||||
|
fn (array $detail): bool => ($detail['detail_key'] ?? null) === 'microsoft_tenant_id'
|
||||||
|
&& ($detail['detail_value'] ?? null) === '22222222-2222-2222-2222-222222222222',
|
||||||
|
))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps dedicated runtime secrets out of target scope and provider context', function (): void {
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||||
|
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'payload' => [
|
||||||
|
'client_id' => 'dedicated-client-id',
|
||||||
|
'client_secret' => 'dedicated-client-secret',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant', 'credential']));
|
||||||
|
$providerContextDetails = collect($resolution->providerContext()['details']);
|
||||||
|
|
||||||
|
expect($resolution->resolved)->toBeTrue()
|
||||||
|
->and($resolution->targetScope?->toArray())->not->toHaveKey('client_secret')
|
||||||
|
->and($providerContextDetails->contains(
|
||||||
|
fn (array $detail): bool => str_contains((string) ($detail['detail_value'] ?? ''), 'dedicated-client-secret'),
|
||||||
|
))->toBeFalse()
|
||||||
|
->and($resolution->effectiveClientId)->toBe('dedicated-client-id');
|
||||||
|
});
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('stores neutral target-scope and nested provider context when provider operations start', function (): void {
|
||||||
|
$tenant = ManagedEnvironment::factory()->create([
|
||||||
|
'name' => 'Spec 281 Environment',
|
||||||
|
'managed_environment_id' => '44444444-4444-4444-4444-444444444444',
|
||||||
|
]);
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(ProviderOperationStartGate::class)->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'provider.connection.check',
|
||||||
|
dispatcher: fn (OperationRun $run): null => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = $result->run->fresh()->context;
|
||||||
|
|
||||||
|
expect($result->status)->toBe('started')
|
||||||
|
->and($context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => '44444444-4444-4444-4444-444444444444',
|
||||||
|
'scope_display_name' => 'Spec 281 Environment',
|
||||||
|
])
|
||||||
|
->and($context['target_scope'] ?? [])->not->toHaveKey('entra_tenant_id')
|
||||||
|
->and($context['provider_context']['details'][0] ?? [])->toMatchArray([
|
||||||
|
'detail_key' => 'microsoft_tenant_id',
|
||||||
|
'detail_value' => '44444444-4444-4444-4444-444444444444',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores neutral target-scope context when provider starts are blocked before a connection is resolved', function (): void {
|
||||||
|
$tenant = ManagedEnvironment::factory()->create([
|
||||||
|
'name' => 'Blocked Spec 281 Environment',
|
||||||
|
'managed_environment_id' => '55555555-5555-5555-5555-555555555555',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(ProviderOperationStartGate::class)->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: null,
|
||||||
|
operationType: 'provider.connection.check',
|
||||||
|
dispatcher: fn (): null => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = $result->run->fresh()->context;
|
||||||
|
$report = $context['verification_report'] ?? [];
|
||||||
|
$report = is_array($report) ? $report : [];
|
||||||
|
$identity = $report['identity'] ?? [];
|
||||||
|
$identity = is_array($identity) ? $identity : [];
|
||||||
|
$evidence = $report['checks'][0]['evidence'] ?? [];
|
||||||
|
$evidence = is_array($evidence) ? $evidence : [];
|
||||||
|
|
||||||
|
expect($result->status)->toBe('blocked')
|
||||||
|
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and($context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing)
|
||||||
|
->and($context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => '55555555-5555-5555-5555-555555555555',
|
||||||
|
])
|
||||||
|
->and($context['target_scope'] ?? [])->not->toHaveKey('entra_tenant_id')
|
||||||
|
->and($identity['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'scope_identifier' => '55555555-5555-5555-5555-555555555555',
|
||||||
|
])
|
||||||
|
->and($identity)->not->toHaveKey('entra_tenant_id')
|
||||||
|
->and(collect($evidence)->contains(
|
||||||
|
fn (array $pointer): bool => ($pointer['kind'] ?? null) === 'target_scope_identifier'
|
||||||
|
&& ($pointer['value'] ?? null) === '55555555-5555-5555-5555-555555555555',
|
||||||
|
))->toBeTrue();
|
||||||
|
});
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Directory\ViewWorkspace;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Filament::setCurrentPanel('system');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes and reopens a workspace through confirmed system actions with audit truth', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create(['name' => 'Closure Workspace']);
|
||||||
|
$workspaceUser = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $workspaceUser->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Closure ManagedEnvironment',
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ManagedEnvironment::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Second Closure ManagedEnvironment',
|
||||||
|
'is_current' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operator = PlatformUser::factory()->create([
|
||||||
|
'name' => 'Platform Directory Operator',
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::DIRECTORY_VIEW,
|
||||||
|
PlatformCapabilities::DIRECTORY_MANAGE,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($operator, 'platform')
|
||||||
|
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
||||||
|
->assertActionVisible('close_workspace')
|
||||||
|
->assertActionExists('close_workspace', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->callAction('close_workspace', data: [
|
||||||
|
'reason' => 'Customer requested closure for offboarding.',
|
||||||
|
])
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Workspace closed')
|
||||||
|
->assertActionVisible('reopen_workspace')
|
||||||
|
->assertActionHidden('close_workspace');
|
||||||
|
|
||||||
|
$workspace->refresh();
|
||||||
|
$tenant->refresh();
|
||||||
|
|
||||||
|
expect($workspace->isClosed())->toBeTrue()
|
||||||
|
->and($workspace->closed_by_platform_user_id)->toBe((int) $operator->getKey())
|
||||||
|
->and($workspace->closureReason())->toBe('Customer requested closure for offboarding.')
|
||||||
|
->and($tenant->is_current)->toBeFalse()
|
||||||
|
->and(ManagedEnvironment::query()->where('workspace_id', (int) $workspace->getKey())->where('is_current', true)->exists())->toBeFalse()
|
||||||
|
->and(WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('user_id', (int) $workspaceUser->getKey())
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
|
||||||
|
$closeAudit = AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('action', AuditActionId::WorkspaceClosed->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($closeAudit)->not->toBeNull()
|
||||||
|
->and($closeAudit?->actor_name)->toBe('Platform Directory Operator')
|
||||||
|
->and($closeAudit?->metadata['reason'] ?? null)->toBe('Customer requested closure for offboarding.')
|
||||||
|
->and($closeAudit?->metadata['after_status'] ?? null)->toBe('closed');
|
||||||
|
|
||||||
|
expect(fn () => app(OperationRunService::class)->ensureRun(
|
||||||
|
tenant: $tenant->fresh(),
|
||||||
|
type: OperationRunType::InventorySync->value,
|
||||||
|
inputs: ['scope' => 'workspace_closure_test'],
|
||||||
|
initiator: $workspaceUser,
|
||||||
|
))->toThrow(InvalidArgumentException::class, 'Workspace is closed');
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
|
||||||
|
Livewire::actingAs($operator, 'platform')
|
||||||
|
->test(ViewWorkspace::class, ['workspace' => $workspace->fresh()])
|
||||||
|
->assertActionVisible('reopen_workspace')
|
||||||
|
->assertActionExists('reopen_workspace', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->callAction('reopen_workspace', data: [
|
||||||
|
'reason' => 'Workspace access is approved again.',
|
||||||
|
])
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Workspace reopened')
|
||||||
|
->assertActionVisible('close_workspace');
|
||||||
|
|
||||||
|
$workspace->refresh();
|
||||||
|
|
||||||
|
expect($workspace->isClosed())->toBeFalse()
|
||||||
|
->and($workspace->closed_by_platform_user_id)->toBeNull()
|
||||||
|
->and($workspace->closureReason())->toBeNull()
|
||||||
|
->and(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('action', AuditActionId::WorkspaceReopened->value)
|
||||||
|
->where('metadata->reason', 'Workspace access is approved again.')
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides workspace closure mutations from platform users without directory manage capability', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$viewer = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::DIRECTORY_VIEW,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($viewer, 'platform')
|
||||||
|
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
||||||
|
->assertActionHidden('close_workspace')
|
||||||
|
->assertActionHidden('reopen_workspace');
|
||||||
|
});
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Filament::setCurrentPanel('system');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps canonical run detail readable for closed workspaces and removed tenants', function (): void {
|
||||||
|
$platformUser = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::OPERATIONS_VIEW,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create([
|
||||||
|
'name' => 'Historical Closure Workspace',
|
||||||
|
'closed_at' => now(),
|
||||||
|
'closed_by_platform_user_id' => (int) $platformUser->getKey(),
|
||||||
|
'closed_reason' => 'Historical closure for support verification.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantUser = User::factory()->create();
|
||||||
|
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Removed Historical ManagedEnvironment',
|
||||||
|
'removed_from_workspace_at' => now(),
|
||||||
|
'removed_from_workspace_by_user_id' => (int) $tenantUser->getKey(),
|
||||||
|
'removed_from_workspace_reason' => 'Tenant was offboarded from the workspace.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()
|
||||||
|
->forTenant($tenant)
|
||||||
|
->create([
|
||||||
|
'type' => OperationRunType::InventorySync->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'started_at' => now()->subMinutes(3),
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform')
|
||||||
|
->get(SystemOperationRunLinks::view($run))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Historical Closure Workspace')
|
||||||
|
->assertSee('Removed Historical ManagedEnvironment')
|
||||||
|
->assertSee('Workspace lifecycle')
|
||||||
|
->assertSee('Closed')
|
||||||
|
->assertSee('Tenant workspace posture')
|
||||||
|
->assertSee('Removed from workspace');
|
||||||
|
});
|
||||||
@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
expect($resolution->resolved)->toBeTrue()
|
expect($resolution->resolved)->toBeTrue()
|
||||||
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
|
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
|
||||||
->and($resolution->tenantContext)->toBe('customer-tenant-id')
|
->and(property_exists($resolution, 'tenantContext'))->toBeFalse()
|
||||||
|
->and($resolution->targetScopeIdentifier())->toBe('customer-tenant-id')
|
||||||
->and($resolution->effectiveClientId)->toBe('platform-client-id')
|
->and($resolution->effectiveClientId)->toBe('platform-client-id')
|
||||||
->and($resolution->credentialSource)->toBe('platform_config')
|
->and($resolution->credentialSource)->toBe('platform_config')
|
||||||
->and($resolution->clientSecret)->toBe('platform-client-secret')
|
->and($resolution->clientSecret)->toBe('platform-client-secret')
|
||||||
|
|||||||
@ -29,9 +29,9 @@
|
|||||||
|
|
||||||
expect($identity->coversPath('app/Services/Providers/ProviderIdentityResolution.php'))->toBeTrue()
|
expect($identity->coversPath('app/Services/Providers/ProviderIdentityResolution.php'))->toBeTrue()
|
||||||
->and($identity->neutralTerms)->toContain('target scope')
|
->and($identity->neutralTerms)->toContain('target scope')
|
||||||
->and($identity->retainedProviderSemantics)->toContain('entra_tenant_id')
|
->and($identity->retainedProviderSemantics)->toContain('provider_context.microsoft_tenant_id')
|
||||||
->and($identity->retainedProviderSemantics)->not->toContain('Microsoft Graph option keys')
|
->and($identity->retainedProviderSemantics)->not->toContain('Microsoft Graph option keys')
|
||||||
->and($identity->followUpAction)->toBe(ProviderBoundarySeam::FOLLOW_UP_SPEC);
|
->and($identity->followUpAction)->toBe(ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE);
|
||||||
|
|
||||||
$registry = $catalog->get('provider.operation_registry');
|
$registry = $catalog->get('provider.operation_registry');
|
||||||
|
|
||||||
@ -48,7 +48,7 @@
|
|||||||
->and($seam->description)->not->toBeEmpty()
|
->and($seam->description)->not->toBeEmpty()
|
||||||
->and($seam->implementationPaths)->not->toBeEmpty()
|
->and($seam->implementationPaths)->not->toBeEmpty()
|
||||||
->and($seam->neutralTerms)->not->toBeEmpty()
|
->and($seam->neutralTerms)->not->toBeEmpty()
|
||||||
->and($seam->retainedProviderSemantics)->toContain('target_scope.entra_tenant_id')
|
->and($seam->retainedProviderSemantics)->toContain('provider_context.microsoft_tenant_id')
|
||||||
->and($seam->followUpAction)->toBeIn([
|
->and($seam->followUpAction)->toBeIn([
|
||||||
ProviderBoundarySeam::FOLLOW_UP_NONE,
|
ProviderBoundarySeam::FOLLOW_UP_NONE,
|
||||||
ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
|
ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
|
||||||
|
|||||||
@ -32,11 +32,12 @@
|
|||||||
|
|
||||||
expect($resolution->resolved)->toBeTrue()
|
expect($resolution->resolved)->toBeTrue()
|
||||||
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
|
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
|
||||||
->and($resolution->tenantContext)->toBe('22222222-2222-2222-2222-222222222222')
|
->and(property_exists($resolution, 'tenantContext'))->toBeFalse()
|
||||||
|
->and($resolution->targetScopeIdentifier())->toBe('22222222-2222-2222-2222-222222222222')
|
||||||
->and($resolution->targetScope)->not->toBeNull()
|
->and($resolution->targetScope)->not->toBeNull()
|
||||||
->and($resolution->targetScope?->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
|
->and($resolution->targetScope?->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
|
||||||
->and($resolution->targetScope?->scopeIdentifier)->toBe('22222222-2222-2222-2222-222222222222')
|
->and($resolution->targetScope?->scopeIdentifier)->toBe('22222222-2222-2222-2222-222222222222')
|
||||||
->and(collect($resolution->contextualIdentityDetails)->pluck('detailKey')->all())
|
->and(collect($resolution->providerContextDetails)->pluck('detailKey')->all())
|
||||||
->toContain('microsoft_tenant_id', 'authority_tenant', 'redirect_uri');
|
->toContain('microsoft_tenant_id', 'authority_tenant', 'redirect_uri');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -88,7 +88,8 @@
|
|||||||
$resolution = app(ProviderIdentityResolver::class)->resolve($connection);
|
$resolution = app(ProviderIdentityResolver::class)->resolve($connection);
|
||||||
|
|
||||||
expect($resolution->resolved)->toBeTrue()
|
expect($resolution->resolved)->toBeTrue()
|
||||||
->and($resolution->tenantContext)->toBe('dedicated-target-tenant-id')
|
->and(property_exists($resolution, 'tenantContext'))->toBeFalse()
|
||||||
|
->and($resolution->targetScopeIdentifier())->toBe('dedicated-target-tenant-id')
|
||||||
->and($resolution->effectiveClientId)->toBe('dedicated-client-id')
|
->and($resolution->effectiveClientId)->toBe('dedicated-client-id')
|
||||||
->and(method_exists($resolution, 'graphOptions'))->toBeFalse();
|
->and(method_exists($resolution, 'graphOptions'))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -53,10 +53,15 @@
|
|||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'health_check',
|
'module' => 'health_check',
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => 'entra-tenant-id',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($run->context['provider_context'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
]);
|
||||||
|
expect($run->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => 'entra-tenant-id',
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
expect($run->context['provider_binding']['provider'] ?? null)->toBe('microsoft')
|
expect($run->context['provider_binding']['provider'] ?? null)->toBe('microsoft')
|
||||||
->and($run->context['provider_binding']['binding_status'] ?? null)->toBe('active');
|
->and($run->context['provider_binding']['binding_status'] ?? null)->toBe('active');
|
||||||
});
|
});
|
||||||
@ -197,10 +202,12 @@
|
|||||||
expect($run->context)->toMatchArray([
|
expect($run->context)->toMatchArray([
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => 'restore-entra-tenant-id',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($run->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => 'restore-entra-tenant-id',
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts directory group sync with explicit provider connection binding and sync capability metadata', function (): void {
|
it('starts directory group sync with explicit provider connection binding and sync capability metadata', function (): void {
|
||||||
@ -239,10 +246,12 @@
|
|||||||
expect($run->context)->toMatchArray([
|
expect($run->context)->toMatchArray([
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'required_capability' => Capabilities::TENANT_SYNC,
|
'required_capability' => Capabilities::TENANT_SYNC,
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => 'directory-entra-tenant-id',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
expect($run->context['target_scope'] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => 'directory-entra-tenant-id',
|
||||||
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats onboarding bootstrap provider starts as one protected scope', function (): void {
|
it('treats onboarding bootstrap provider starts as one protected scope', function (): void {
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
# Specification Quality Checklist: Tenant Dashboard Active Operations Summary Card
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
|
||||||
|
**Created**: 2026-05-07
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] The package stays on one compact Tenant Dashboard active-operations summary card over existing `OperationRun` truth instead of widening into a full shell banner, dashboard-native operations console, or new widget framework.
|
||||||
|
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff.
|
||||||
|
- [x] The package explicitly names the repo-real anchors it builds on: `TenantDashboardSummaryBuilder`, `TenantDashboardOverview`, `ActiveRuns`, `OperationRunLinks`, `OperationUxPresenter`, existing `OperationRun` dashboard scopes, and the current dashboard Feature and browser proof owners.
|
||||||
|
- [x] Mandatory repo sections for scope, shared-pattern reuse, Ops-UX, testing, proportionality, and manual-promotion rationale are completed.
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No unresolved clarification markers remain.
|
||||||
|
- [x] Requirements stay testable and bounded to the no-active hidden state, queued or running visible state, stale or follow-up-needed priority, canonical links, visibility or capability gating, and tenant or workspace isolation.
|
||||||
|
- [x] The package explicitly preserves one dominant `View operation` action plus the neutral canonical `Show all operations` action.
|
||||||
|
- [x] The package explicitly forbids a full dashboard shell banner, new persistence, new lifecycle ownership, a new widget family, a route or panel family change, provider or asset changes, and raw diagnostics expansion.
|
||||||
|
- [x] Planned validation commands now match across `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
|
||||||
|
## Candidate Selection Gate
|
||||||
|
|
||||||
|
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and is a deliberate manual promotion that stays directionally consistent with the roadmap's dashboard/core-surface guidance after Specs `268` through `272`, even though it is not called out as an explicitly ranked roadmap item.
|
||||||
|
- [x] The automatic next-best-prep queue is intentionally empty, so this package records itself as a deliberate manual promotion rather than an automatic queue pick.
|
||||||
|
- [x] Repo verification confirms the current Tenant Dashboard already has the `Active operations` KPI and `Recent operations` history surface but still lacks the dedicated compact active-operations summary card this package defines.
|
||||||
|
- [x] The chosen slice is smaller and safer than deferred alternatives such as a dashboard shell-banner rollout, a new dashboard operations console, or broader progress redesign work.
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] The package reuses current `OperationRun` truth and current dashboard summary composition instead of introducing a second lifecycle, a persisted projection, or a dashboard-only query family.
|
||||||
|
- [x] The package explicitly keeps tenant or workspace isolation and `404` or suppression behavior intact for non-members and actors without `OperationRun` visibility.
|
||||||
|
- [x] The package forbids new panel, provider, global-search, asset-registration, queue-family, notification-policy, and raw-diagnostics changes.
|
||||||
|
- [x] The tasks artifact names the likely implementation and proof files already identified in `plan.md`.
|
||||||
|
- [x] The review artifact, workflow outcome, and test-governance outcome are carried into the active prep package.
|
||||||
|
|
||||||
|
## Test Governance
|
||||||
|
|
||||||
|
- [x] Planned proof stays bounded to focused `Feature` coverage plus one named dashboard `Browser` smoke.
|
||||||
|
- [x] No new heavy-governance family or broad browser family is introduced by default.
|
||||||
|
- [x] Fixture growth remains bounded to current tenant dashboard helpers, `OperationRun` factories, tenant context setup, and the existing dashboard browser scaffolding.
|
||||||
|
- [x] The proving commands stay file-scoped, run through Sail, and keep DB-only visibility or isolation proof explicit instead of widening into unrelated lanes.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Reviewed against `specs/273-tenant-dashboard-active-operations-summary-card/spec.md`, `specs/273-tenant-dashboard-active-operations-summary-card/plan.md`, `specs/273-tenant-dashboard-active-operations-summary-card/tasks.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `.specify/memory/constitution.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php`, `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php`, `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`, `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, and `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` on 2026-05-07.
|
||||||
|
- This checklist is the prep-time outcome record. If implementation widens into a full shell banner, a new widget family, new progress semantics, new persistence, route or panel changes, or raw-diagnostics expansion, the workflow outcome must change before merge.
|
||||||
|
- No application implementation was performed while preparing this package.
|
||||||
|
|
||||||
|
## Review Outcome
|
||||||
|
|
||||||
|
- **Outcome class**: `acceptable-special-case`
|
||||||
|
- **Workflow outcome**: `keep`
|
||||||
|
- **Test-governance outcome**: `keep`
|
||||||
|
- **Reason**: the automatic queue is intentionally empty, but repo truth still shows one bounded unspecced dashboard seam after Specs `268` through `272`: the Tenant Dashboard already has aggregate and recent-history signals, yet still lacks the compact active-operations summary card this package defines.
|
||||||
|
- **Workflow result**: Ready for implementation.
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
# Implementation Plan: Tenant Dashboard Operations Curation & Decision-First UX
|
||||||
|
|
||||||
|
**Branch**: `273-tenant-dashboard-active-operations-summary-card` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This scope refresh narrows the Tenant Dashboard back to decision-first operations UX. The dashboard keeps one attention KPI, one attention-only recommended action, and one compact `Operations requiring attention` card. It stops rendering recent operations history on the dashboard and pushes detail/history back to the canonical Operations Hub.
|
||||||
|
|
||||||
|
## Implementation Shape
|
||||||
|
|
||||||
|
- Keep all logic inside the existing `TenantDashboardSummaryBuilder` + `TenantDashboardOverview` path.
|
||||||
|
- Reuse `OperationRun::dashboardNeedsFollowUp()`, `OperationRunLinks`, and `OperationUxPresenter`.
|
||||||
|
- Reuse canonical Operations Hub filters by dominant real problem class.
|
||||||
|
- Remove the recent-operations overview section from the tenant dashboard Blade.
|
||||||
|
- Keep Filament v5 + Livewire v4, no provider-registration changes, no new panels/resources/assets.
|
||||||
|
|
||||||
|
## Affected Surfaces
|
||||||
|
|
||||||
|
- `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php`
|
||||||
|
- `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php`
|
||||||
|
- `apps/platform/lang/en/localization.php`
|
||||||
|
- `apps/platform/lang/de/localization.php`
|
||||||
|
- `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||||
|
- `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
### Query contract
|
||||||
|
|
||||||
|
- Centralize dashboard attention logic in one tenant/workspace-scoped attention query.
|
||||||
|
- Attention means only `dashboardNeedsFollowUp()` runs.
|
||||||
|
- Healthy active runs are excluded from the dashboard decision card.
|
||||||
|
|
||||||
|
### KPI contract
|
||||||
|
|
||||||
|
- Keep the KPI slot and chart.
|
||||||
|
- Replace history-window wording with attention-only decision copy.
|
||||||
|
- KPI click remains a canonical Operations Hub drill-through.
|
||||||
|
|
||||||
|
### Recommended action contract
|
||||||
|
|
||||||
|
- Promote operations attention above informative readiness states.
|
||||||
|
- Keep it below missing permissions and high severity findings.
|
||||||
|
- Use exact review-oriented copy from the scope refresh.
|
||||||
|
|
||||||
|
### Card contract
|
||||||
|
|
||||||
|
- Show at most three attention items.
|
||||||
|
- Per item: title, outcome sentence, reason, impact, time, `Review operation`.
|
||||||
|
- Card-level secondary CTA: `Open operations hub`.
|
||||||
|
- No recent-history list remains on the dashboard.
|
||||||
|
|
||||||
|
## RBAC / Isolation
|
||||||
|
|
||||||
|
- Tenant membership stays the first gate.
|
||||||
|
- All counts and items remain scoped by `managed_environment_id` and `workspace_id`.
|
||||||
|
- If the actor cannot access tenant operations, the dashboard card and links stay hidden.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Focused tests:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
|
||||||
|
- Formatting:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not add a second operations inbox or history block to the dashboard.
|
||||||
|
- Do not invent dashboard-only filters or route strings.
|
||||||
|
- Do not expose raw diagnostics or payload details on the dashboard.
|
||||||
|
- Do not change provider registration, global search, or destructive-action behavior.
|
||||||
|
|
||||||
|
### Phase 1 - Add one derived active-operations summary payload
|
||||||
|
|
||||||
|
- Extend `TenantDashboardSummaryBuilder` with one compact summary payload that returns count, highlighted run, status/guidance, and canonical navigation actions.
|
||||||
|
- Reuse `ActiveRuns`, `OperationRun::dashboardNeedsFollowUp()`, `OperationUxPresenter`, and `OperationRunLinks` instead of inventing a new query or presenter layer.
|
||||||
|
- Keep ranking deterministic: follow-up-needed/stale first, healthy queued/running second, then recency.
|
||||||
|
|
||||||
|
### Phase 2 - Render the card inside the existing overview composition
|
||||||
|
|
||||||
|
- Add the compact card to `tenant-dashboard-overview.blade.php` inside the current main-column composition.
|
||||||
|
- Use current dashboard card language and Filament primitives, keep one dominant `View operation` action, and keep `Show all operations` neutral.
|
||||||
|
- Hide the card entirely when no qualifying visible signal exists.
|
||||||
|
|
||||||
|
### Phase 3 - Prove calmness, truth, and tenant-safe visibility
|
||||||
|
|
||||||
|
- Extend focused Feature coverage for visible, hidden, priority, and shared-link behavior.
|
||||||
|
- Extend or reuse the existing negative tenant dashboard feature proof for non-member or no-OperationRun-visibility suppression.
|
||||||
|
- Update the existing browser smoke so the real dashboard first screen proves the card appears only when warranted and does not regress the calm layout.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: the current Tenant Dashboard can show operations only as aggregate posture or recent history, which forces the operator to infer whether current tenant work needs attention right now.
|
||||||
|
- **Existing structure is insufficient because**: the `Active operations` KPI is aggregate-only, and `Recent operations` is history-oriented. Neither surface provides one truthful highlighted run plus a dominant next action.
|
||||||
|
- **Narrowest correct implementation**: one derived summary payload inside the current dashboard summary builder plus one compact card in the existing overview view.
|
||||||
|
- **Ownership cost created**: a small summary-builder extension, one dashboard render slice, and focused feature/browser proof that must stay aligned with shared OperationRun helpers.
|
||||||
|
- **Alternative intentionally rejected**: reusing the full shell banner or building a new Operations widget family was rejected because both would make the dashboard louder and broader than the governance-first contract requires.
|
||||||
|
- **Release truth**: current-release truth. The repo already ships the dashboard, KPI, recent-operation cards, and canonical Operations surfaces; this plan only fills the bounded missing summary seam.
|
||||||
@ -0,0 +1,305 @@
|
|||||||
|
# Feature Specification: Tenant Dashboard Operations Curation & Decision-First UX
|
||||||
|
|
||||||
|
**Feature Branch**: `273-tenant-dashboard-active-operations-summary-card`
|
||||||
|
**Created**: 2026-05-07
|
||||||
|
**Status**: Ready for implementation
|
||||||
|
**Input**: repo-based scope refresh from the original active-operations-summary slice after product review identified dashboard drift: operations had become visible in too many places on the Tenant Dashboard and needed to be reduced to decision-only attention surfaces.
|
||||||
|
|
||||||
|
## Spec Candidate Check
|
||||||
|
|
||||||
|
- **Problem**: the Tenant Dashboard currently exposes operation truth through too many surfaces at once: KPI, recommended action, attention card, and recent-history rendering. That pushes the dashboard toward a second Operations hub instead of a governance-first decision surface.
|
||||||
|
- **User-visible improvement**: the dashboard keeps only attention-relevant operations. Healthy active or recent historical runs stop taking space on the first screen, while stale or follow-up-needed runs stay visible with one clear next action.
|
||||||
|
- **Smallest enterprise-capable version**: keep the `Active operations` KPI as an attention KPI, add one review-oriented recommended action when operations need follow-up, render one compact `Operations requiring attention` card with at most 1-3 runs, and remove the recent-operations section from the dashboard overview.
|
||||||
|
- **Explicit non-goals**: no second operations console, no raw diagnostics on the dashboard, no new persistence, no new OperationRun lifecycle, no new panel/resource/search surface, no route-family changes, and no compatibility shims.
|
||||||
|
- **Why now**: repo truth already ships canonical Operations list/detail surfaces. The remaining gap is not more operations visibility, but curation: the dashboard must stop duplicating history/detail surfaces and only show decision-relevant execution truth.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **Primary route**: `/admin/t/{tenant}` Tenant Dashboard
|
||||||
|
- **Canonical drill-through routes**:
|
||||||
|
- `/admin/operations`
|
||||||
|
- `/admin/operations/{run}`
|
||||||
|
- **Source of truth**: existing tenant-scoped `operation_runs` only
|
||||||
|
- **RBAC**: existing tenant membership and operation visibility remain authoritative. Non-members remain `404`, and no card, count, or CTA may leak hidden runs.
|
||||||
|
|
||||||
|
## Shared Pattern Reuse
|
||||||
|
|
||||||
|
- **Cross-cutting feature**: yes
|
||||||
|
- **Interaction classes**: dashboard KPI, recommended actions, decision card, canonical run navigation
|
||||||
|
- **Shared contracts reused**: `OperationRunLinks`, `OperationUxPresenter`, `ActiveRuns`, `TenantDashboardSummaryBuilder`
|
||||||
|
- **Required consistency**:
|
||||||
|
- Dashboard remains decision-first
|
||||||
|
- Operations Hub remains diagnostics/history-first
|
||||||
|
- `OperationRun` remains execution truth
|
||||||
|
- No local dashboard-only run lifecycle or fake filters
|
||||||
|
|
||||||
|
## UX Contract
|
||||||
|
|
||||||
|
### A. KPI Card
|
||||||
|
|
||||||
|
- Keep the existing `Active operations` KPI slot.
|
||||||
|
- Count only attention-relevant operations.
|
||||||
|
- Show one short decision sentence:
|
||||||
|
- `No operations need attention`
|
||||||
|
- `1 operation needs follow-up`
|
||||||
|
- `:count operations require attention`
|
||||||
|
- KPI click opens the canonical Operations Hub in current tenant context with the dominant real attention filter.
|
||||||
|
|
||||||
|
### B. Recommended Next Actions
|
||||||
|
|
||||||
|
- Show a recommended action only when attention-relevant operations exist.
|
||||||
|
- Required copy:
|
||||||
|
- **Title**: `Review operations requiring attention`
|
||||||
|
- **Reason**: `One or more operations finished with an outcome that needs follow-up.`
|
||||||
|
- **Impact**: `The tenant should not be treated as fully healthy until the operation outcome has been reviewed.`
|
||||||
|
- **Primary CTA**: `Review operations`
|
||||||
|
- Priority rule:
|
||||||
|
- lower than missing provider permissions
|
||||||
|
- lower than high severity findings
|
||||||
|
- higher than purely informative readiness/status items
|
||||||
|
|
||||||
|
### C. Operations Requiring Attention Card
|
||||||
|
|
||||||
|
- Render only when at least one attention-relevant run exists.
|
||||||
|
- Card title: `Operations requiring attention`
|
||||||
|
- Show at most 1-3 operations.
|
||||||
|
- Per operation show:
|
||||||
|
- clear title
|
||||||
|
- human-readable outcome sentence
|
||||||
|
- reason
|
||||||
|
- impact
|
||||||
|
- relative time
|
||||||
|
- primary CTA: `Review operation`
|
||||||
|
- Card-level secondary CTA: `Open operations hub`
|
||||||
|
- Healthy queued/running work is excluded from this card.
|
||||||
|
- Recent-history rendering is not shown on the Tenant Dashboard.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. The Tenant Dashboard does not render a Recent Operations section.
|
||||||
|
2. Healthy queued/running operations do not create a dashboard attention card.
|
||||||
|
3. Terminal follow-up and stale-active runs do create a dashboard attention signal.
|
||||||
|
4. The KPI uses attention-only decision copy, not history-window copy.
|
||||||
|
5. The recommended action appears only when attention-relevant operations exist and uses the required copy.
|
||||||
|
6. The attention card shows at most 1-3 operations and each operation has `Review operation`.
|
||||||
|
7. `Open operations hub` and KPI drill-through use canonical `OperationRunLinks` tenant-scoped URLs.
|
||||||
|
8. The dashboard exposes no raw diagnostics, no execution-history table, and no additional operations overview block.
|
||||||
|
9. Cross-tenant or hidden runs never affect dashboard counts, items, or links.
|
||||||
|
|
||||||
|
## Test & Validation
|
||||||
|
|
||||||
|
- **Test classification**: Feature + Browser
|
||||||
|
- **Validation commands**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- No Livewire v3 / Filament v3-v4 patterns. This remains Filament v5 + Livewire v4.
|
||||||
|
- No provider-registration changes; `apps/platform/bootstrap/providers.php` remains authoritative.
|
||||||
|
- No global-search impact.
|
||||||
|
- No destructive actions are introduced.
|
||||||
|
- non-member / not entitled to workspace scope OR tenant scope -> 404 (deny-as-not-found)
|
||||||
|
- member but missing capability -> 403
|
||||||
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||||
|
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||||
|
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- how the affected surface follows `docs/ui/tenantpilot-enterprise-ui-standards.md`,
|
||||||
|
- which native Filament components or shared UI primitives are used,
|
||||||
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- how the feature avoids ad-hoc custom styling for cards, buttons, hovers, badges, icons, progress bars, empty states, and interactive rows,
|
||||||
|
- how any custom Blade, Livewire widget, page, or dashboard surface preserves Filament-native interaction semantics and avoids introducing an independent button, status-color, spacing, or card system,
|
||||||
|
- how each affected page or focused action area keeps at most one dominant primary action and keeps secondary actions neutral unless they are destructive or the semantic state change is the point of the action,
|
||||||
|
- how status is conveyed through BADGE-001 badges, labels, chips, or supporting text rather than arbitrary button colors or per-card custom action styling,
|
||||||
|
- how hover, pointer, focus, shadow, or similar interactive affordance is used only when a repo-real route/action and permitted capability exist, and how non-interactive rows remain visibly static,
|
||||||
|
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language, and are used to compose product-specific layout rather than a parallel local design system,
|
||||||
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
|
- the target object,
|
||||||
|
- the operator verb,
|
||||||
|
- whether source/domain disambiguation is actually needed,
|
||||||
|
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||||
|
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** If this feature adds or changes operator-facing surfaces, the spec MUST describe:
|
||||||
|
- whether each affected surface is a Primary Decision Surface,
|
||||||
|
Secondary Context Surface, or Tertiary Evidence / Diagnostics
|
||||||
|
Surface, and why,
|
||||||
|
- which human-in-the-loop moment each primary surface supports,
|
||||||
|
- what MUST be visible immediately for the first decision,
|
||||||
|
- what is preserved but only revealed on demand,
|
||||||
|
- why any new primary surface cannot live inside an existing decision
|
||||||
|
context,
|
||||||
|
- how navigation follows operator workflows rather than storage
|
||||||
|
structures,
|
||||||
|
- how one governance case remains decidable in one focused context,
|
||||||
|
- how any new automation, notifications, or autonomous governance logic
|
||||||
|
reduce search/review/click load,
|
||||||
|
- and how the resulting default experience is calmer and clearer rather
|
||||||
|
than merely larger.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||||
|
- the chosen broad action-surface class and why it is the correct classification,
|
||||||
|
- the chosen detailed surface type and why it is the correct refinement,
|
||||||
|
- the one most likely next operator action,
|
||||||
|
- the one and only primary inspect/open model,
|
||||||
|
- whether row click is required, allowed, or forbidden,
|
||||||
|
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||||
|
- where pure navigation lives and why it is not competing with mutation,
|
||||||
|
- where secondary actions live,
|
||||||
|
- where destructive actions live,
|
||||||
|
- how grouped actions are ordered by meaning, frequency, and risk,
|
||||||
|
- the canonical collection route and canonical detail route,
|
||||||
|
- the scope signals shown to the operator and what real effect each one has,
|
||||||
|
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
||||||
|
- which critical operational truth is visible by default,
|
||||||
|
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** If this
|
||||||
|
feature adds or materially changes header actions, row actions, bulk
|
||||||
|
actions, or workbench controls, the spec MUST describe:
|
||||||
|
- how navigation, mutation, context signals, selection actions, and
|
||||||
|
dangerous actions are separated,
|
||||||
|
- why any visible secondary action deserves primary-plane placement,
|
||||||
|
- why any ActionGroup is structured rather than a mixed catch-all,
|
||||||
|
- and why any workflow-hub, wizard, system, or other special-type
|
||||||
|
exception is genuine rather than a convenience shortcut.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
|
- how the dominant next action stays primary and how duplicate visible truth is avoided,
|
||||||
|
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||||
|
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||||
|
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||||
|
- and the page contract for each new or materially refactored operator-facing page.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||||
|
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||||
|
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||||
|
- which existing layer is replaced or why no existing layer can serve,
|
||||||
|
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||||
|
- and how tests focus on business consequences rather than thin indirection alone.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
|
the spec MUST include a `UI Action Matrix` and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
|
The same section MUST state that each affected surface has exactly one primary inspect/open model, that redundant View actions are absent,
|
||||||
|
that empty `ActionGroup` / `BulkActionGroup` placeholders are absent, and that destructive actions follow the required placement rules for the chosen surface type.
|
||||||
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 - Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||||
|
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||||
|
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||||
|
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
|
||||||
|
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The Tenant Dashboard MUST add only one compact active-operations summary surface inside the existing dashboard overview composition. It MUST NOT render the full Spec `268` shell activity banner on the dashboard by default.
|
||||||
|
- **FR-002**: Card eligibility, count, and highlighted-run truth MUST be derived from existing tenant-scoped OperationRun truth and shared helpers already used for activity or follow-up visibility. The dashboard MUST NOT invent a second lifecycle query model or dashboard-only status taxonomy.
|
||||||
|
- **FR-003**: When one or more qualifying visible runs are queued, running, stale, or otherwise still need follow-up in the current tenant, the dashboard MUST show one compact summary card that includes a count and one highlighted run.
|
||||||
|
- **FR-004**: Highlighted-run selection MUST prioritize follow-up-needed or stale attention over healthy queued or running work. When multiple runs share the same attention class, the highlighted run MUST fall back to a deterministic recency rule.
|
||||||
|
- **FR-005**: Default-visible card content MUST stay limited to concise count text, highlighted run label, one centralized status treatment, one short guidance line, and the canonical `View operation` and `Show all operations` navigation actions.
|
||||||
|
- **FR-006**: `View operation` MUST open the canonical detail route for the highlighted run through shared link helpers. `Show all operations` MUST open the canonical Operations collection in current tenant context through shared link helpers. Dashboard code MUST NOT hardcode raw route strings.
|
||||||
|
- **FR-007**: Failed, blocked, stale, or follow-up-needed work MUST receive stronger visual priority than healthy queued or running work. Healthy active work MUST remain a calm secondary signal rather than an alert banner.
|
||||||
|
- **FR-008**: When more than one qualifying run exists, the card MUST stay compact by showing one highlighted run plus the aggregate count and collection drill-through. It MUST NOT expand into a multi-row operations list, table, or dashboard-native inbox.
|
||||||
|
- **FR-009**: When no qualifying active or follow-up-needed runs are visible to the current actor, the active-operations summary card MUST remain hidden by default. Recently completed successful runs stay represented through `Recent operations` rather than keeping this card open.
|
||||||
|
- **FR-010**: The card MUST reuse the dashboard's existing polling cadence and current first-screen density constraints. It MUST NOT add a second poller, floating overlay, or full-width panel that competes with recommended actions, governance status, or readiness cards.
|
||||||
|
- **FR-011**: The card MUST complement rather than duplicate the existing `Active operations` KPI and `Recent operations` section. The KPI remains aggregate posture, `Recent operations` remains recent history, and the new card remains the current active-or-follow-up summary.
|
||||||
|
- **FR-012**: The surface MUST stay governance-first and decision-first: one dominant next action, diagnostics-secondary disclosure, no raw implementation detail, and no dashboard-local operations-console affordances.
|
||||||
|
|
||||||
|
### Authorization and Safety Requirements
|
||||||
|
|
||||||
|
- **AR-001**: Existing tenant/admin-plane authorization semantics remain unchanged: non-members or out-of-scope tenant actors remain `404`, while canonical Operations routes continue using current OperationRun authorization for in-scope actors.
|
||||||
|
- **AR-002**: The card's count, highlighted run, and both navigation actions MUST be limited to runs the current actor can already view through canonical Operations routes. If the actor cannot view OperationRuns for the current tenant, the card MUST stay hidden.
|
||||||
|
- **AR-003**: No mutating or destructive action is introduced. Both card actions are navigation-only and must not imply acknowledgement, retry, dismissal, or lifecycle mutation.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- **NFR-001**: Filament remains v5 on Livewire v4. No panel-provider registration change is allowed, and `bootstrap/providers.php` remains authoritative.
|
||||||
|
- **NFR-002**: No new panel, globally searchable resource, or asset-registration strategy is introduced. `filament:assets` deployment behavior is unchanged.
|
||||||
|
- **NFR-003**: The card must use existing dashboard composition patterns and Filament-native primitives or current shared dashboard styles. It must not introduce a new local card, badge, button, or hover language outside the current dashboard family.
|
||||||
|
- **NFR-004**: Counted, phased, or composite progress semantics remain owned by Specs `270`, `271`, and `272`. This feature may consume current shared status and guidance truth, but it must not invent progress meters, fake percentages, or dashboard-only phase language.
|
||||||
|
- **NFR-005**: The implementation must preserve current dark-mode correctness, responsive first-screen stability, and current dashboard interaction honesty. Non-interactive areas remain static, and navigation affordances appear only where real routes and permissions exist.
|
||||||
|
|
||||||
|
## Deferred Follow-Ups / Explicit Non-Goals
|
||||||
|
|
||||||
|
- dashboard-native shell-banner redesign or any full-width activity banner on the Tenant Dashboard
|
||||||
|
- counted-progress dashboard treatment already owned by `270` and `271`
|
||||||
|
- phase or composite progress treatment on the dashboard already owned by `272`
|
||||||
|
- a dashboard-native operations inbox, table, or console
|
||||||
|
- workspace-level or cross-tenant active-operations summary surfaces
|
||||||
|
- raw diagnostics, logs, payloads, or support-only execution evidence on the dashboard
|
||||||
|
- broader manual-promotion candidates such as `Governance Artifact Lifecycle & Retention v1` and `Enterprise Access Boundary & Support Access Governance v1`
|
||||||
|
|
||||||
|
## Key Entities
|
||||||
|
|
||||||
|
- **Active Operations Summary Card**: a derived, non-persisted Tenant Dashboard card that summarizes current active or follow-up-needed OperationRun truth for the current tenant without becoming a second operations console.
|
||||||
|
- **Highlighted Operation Summary**: the single derived run preview inside the card that determines the dominant next action and keeps `View operation` truthful when multiple qualifying runs exist.
|
||||||
|
- **Qualifying Dashboard Operation Signal**: an existing tenant-scoped OperationRun that is still active, stale, or otherwise needs follow-up and therefore deserves compact dashboard visibility.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Focused Feature proof shows the Tenant Dashboard rendering one compact active-operations summary card when at least one qualifying visible run exists, with one highlighted run plus canonical `View operation` and `Show all operations` actions.
|
||||||
|
- **SC-002**: Focused Feature proof shows stale or follow-up-needed work outranking healthy queued or running work whenever both are visible for the current tenant.
|
||||||
|
- **SC-003**: Focused Feature plus browser proof shows the dashboard remaining calm when no qualifying visible run exists: no summary card, no full shell banner, and no first-screen layout regression.
|
||||||
|
- **SC-004**: Focused negative visibility proof shows actors without OperationRun visibility receiving no card content, no count leak, and no operation drill-through hints on the dashboard.
|
||||||
|
- **SC-005**: Covered dashboard scenarios keep the existing `Active operations` KPI and `Recent operations` section present and non-contradictory, with no second OperationRun truth source introduced.
|
||||||
|
|
||||||
|
## Candidate Selection Rationale
|
||||||
|
|
||||||
|
- **Selected candidate**: Tenant Dashboard Active Operations Summary Card
|
||||||
|
- **Source locations**:
|
||||||
|
- `docs/product/spec-candidates.md`
|
||||||
|
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
||||||
|
- `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php`
|
||||||
|
- `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-overview.blade.php`
|
||||||
|
- `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php`
|
||||||
|
- `apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`
|
||||||
|
- `apps/platform/tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
|
||||||
|
- **Why selected**: the automatic next-best-prep queue is intentionally empty, Specs `268` through `272` already exist around OperationRun maturity, and repo exploration confirms the exact gap named by candidate `273`: the Tenant Dashboard currently has recent-operation cards and an `Active operations` KPI but no dedicated active-only summary card.
|
||||||
|
- **Why this is the smallest viable implementation slice**: it adds one compact dashboard-native summary over existing shared OperationRun truth and canonical links, without reopening shell-banner scope, progress semantics, new persistence, or new widget infrastructure.
|
||||||
|
- **Why close alternatives were deferred**:
|
||||||
|
- Specs `269` through `272` are already specced and therefore are not the next unspecced repo-ready prep target
|
||||||
|
- `Governance Artifact Lifecycle & Retention v1` and `Enterprise Access Boundary & Support Access Governance v1` remain broader, less bounded manual-promotion items that require larger product decisions than this dashboard follow-up
|
||||||
|
|
||||||
|
## Related-Spec Guardrail Check
|
||||||
|
|
||||||
|
- `specs/268-operationrun-activity-feedback/`: this feature reuses the same run truth family but must not transplant the full shell banner onto the Tenant Dashboard.
|
||||||
|
- `specs/269-operationrun-terminal-outcome-feedback/`: any follow-up-needed or stale prioritization on the dashboard must stay aligned with the shell's terminal-outcome honesty rather than inventing a dashboard-only urgency model.
|
||||||
|
- `specs/270-operationrun-progress-contract/`: the dashboard card must consume shared OperationRun truth and must not invent local progress heuristics or fake percentages.
|
||||||
|
- `specs/271-counted-progress-rollout/`: any richer progress meter remains out of scope here and must flow through the counted-progress contract if it is ever added later.
|
||||||
|
- `specs/272-operationrun-phase-composite-progress/`: phase/composite progress language remains deferred and must not be smuggled into the dashboard card before the shared progress contract owns it.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The canonical Operations collection already supports current-tenant drill-through behavior without requiring a new route family for this feature.
|
||||||
|
- Existing OperationRun helpers are sufficient to support highlighted-run selection and canonical link generation without introducing a new shared presenter layer.
|
||||||
|
- For v1, the calmest interpretation of the candidate's `hidden or calm empty state` rule is to keep the card hidden when there is no qualifying visible signal.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- The card can become noisy or redundant if it duplicates the `Active operations` KPI or `Recent operations` instead of keeping one distinct current-summary role.
|
||||||
|
- A dashboard-local prioritization rule can drift from shared shell or Operations semantics if the implementation stops reusing current shared helpers.
|
||||||
|
- Permission or tenant-filter mistakes could leak run counts or run existence through the summary card even if the canonical Operations route remains protected.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None blocking safe implementation. If layout pressure later proves that a persistent empty-state slot is necessary, that should remain a narrow presentation decision and must not widen this slice into a second dashboard operations surface.
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
description: "Task list for Tenant Dashboard Operations Curation & Decision-First UX"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Tenant Dashboard Operations Curation & Decision-First UX
|
||||||
|
|
||||||
|
- [x] T001 Refresh the active local spec scope so the Tenant Dashboard slice is explicitly attention-only and no longer describes a recent-operations surface on the dashboard.
|
||||||
|
- [x] T002 Update the focused dashboard tests to the new contract: no recent-operations section, attention-only KPI copy, operations attention recommended action, and `Operations requiring attention` card with `Review operation` / `Open operations hub`.
|
||||||
|
- [x] T003 Re-run the focused dashboard test slice to capture the exact implementation failures against the new contract.
|
||||||
|
- [x] T004 Refactor `TenantDashboardSummaryBuilder` to centralize a tenant/workspace-scoped attention query and reuse it for KPI counts, recommended action visibility, and the operations attention card.
|
||||||
|
- [x] T005 Replace the old single highlighted-run payload with a curated 1-3 item attention-card payload that exposes title, outcome sentence, reason, impact, relative time, and canonical review links.
|
||||||
|
- [x] T006 Update dashboard localization so KPI and recommended-action copy match the new decision-first operations wording in EN and DE.
|
||||||
|
- [x] T007 Remove the recent-operations section from the tenant dashboard overview Blade and render the new attention-card layout with one card-level hub CTA and per-item review CTAs.
|
||||||
|
- [x] T008 Keep the dashboard navigation and RBAC contract canonical: no new route strings, no cross-tenant leakage, no destructive actions, and no dashboard-owned operations history surface.
|
||||||
|
- [x] T009 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`.
|
||||||
|
- [x] T010 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- [x] T011 Review the changed slice against Filament v5 / Livewire v4 guardrails, canonical Operations Hub ownership, and dashboard decision-first UX boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||||
|
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks user-story work.
|
||||||
|
- **Phase 3 (US1)**: depends on Phase 2 and establishes the compact summary payload plus render slice.
|
||||||
|
- **Phase 4 (US2)**: depends on US1 because the attention-first priority rule refines the highlighted summary already introduced there.
|
||||||
|
- **Phase 5 (US3)**: depends on US1 and should land with US2 so the summary stays both truthful and quiet.
|
||||||
|
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: independently testable after Phase 2 and delivers the core compact-summary contract.
|
||||||
|
- **US2 (P1)**: independently testable after US1 and delivers the follow-up-first attention ordering that makes the summary trustworthy.
|
||||||
|
- **US3 (P1)**: independently testable after US1 and closes the no-signal or no-visibility calmness contract.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Extend the listed Pest coverage first and make it fail for the intended gap.
|
||||||
|
- Keep runtime edits inside the current summary builder, current overview view, and current shared OperationRun helpers rather than introducing new dashboard infrastructure.
|
||||||
|
- Re-run the narrowest affected validation command after each story checkpoint before moving on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- MVP = **US1 + US2 + US3 together**. The dashboard slice is only truthful once it appears when warranted, prioritizes stale or follow-up-needed work, and disappears when no qualifying or visible signal exists.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Phase 1 and Phase 2.
|
||||||
|
2. Deliver US1 so the compact summary payload and card exist.
|
||||||
|
3. Deliver US2 so the highlighted run stays attention-first.
|
||||||
|
4. Deliver US3 so the no-signal and no-visibility behavior stays calm and leak-free.
|
||||||
|
5. Finish with focused validation and the review-artifact close-out checks.
|
||||||
|
|
||||||
|
### Team Strategy
|
||||||
|
|
||||||
|
1. Settle the proof owner first.
|
||||||
|
2. Parallelize Feature and browser proof updates while keeping runtime changes local to the current summary builder and overview view.
|
||||||
|
3. Serialize merges around `TenantDashboardSummaryBuilder.php` and `tenant-dashboard-overview.blade.php` so the compact card contract stays coherent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred Follow-Ups / Non-Goals
|
||||||
|
|
||||||
|
- full shell activity banner rollout on the Tenant Dashboard
|
||||||
|
- dashboard-native operations console or a new dashboard widget framework
|
||||||
|
- counted, phased, or composite progress rollout work already owned by Specs `270` through `272`
|
||||||
|
- new `OperationRun` lifecycle, queue, or notification-policy changes
|
||||||
|
- new persistence, cached summary projection, or raw-diagnostics expansion on the dashboard
|
||||||
|
- route-family, panel, provider, asset, or global-search changes
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
# Specification Quality Checklist: Provider Connection Scope & Microsoft Profile Extraction
|
||||||
|
|
||||||
|
**Purpose**: Validate package completeness, boundedness, and readiness before implementation
|
||||||
|
**Created**: 2026-05-07
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] The package stays on reserved slot `281` and does not silently absorb Spec `280` or Specs `282`-`287`.
|
||||||
|
- [x] The stale candidate wording about `provider_connections.tenant_id` is explicitly corrected to current repo truth.
|
||||||
|
- [x] The package explicitly documents the second candidate deviation: the raw `provider_key` / `external_account_id` / `provider_metadata` / run-context proposal is narrowed to existing repo truth through `target_scope`, `effective_client_identity`, nested `provider_context`, and existing provider-owned metadata.
|
||||||
|
- [x] The package stays focused on the verified provider-boundary hotspot instead of reading like a speculative provider-platform rewrite.
|
||||||
|
- [x] No new provider-profile table, registry, capability engine, or artifact taxonomy is pulled into scope.
|
||||||
|
- [x] `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, and the contract artifact all describe the same bounded slice.
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No `[NEEDS CLARIFICATION]` markers remain in `spec.md`, `plan.md`, `research.md`, `data-model.md`, or `quickstart.md`.
|
||||||
|
- [x] Requirements remain testable and bounded to the current provider-connection, target-scope, identity-resolution, onboarding, and operation-start seams.
|
||||||
|
- [x] Shared `target_scope` fields are explicit and neutral across the package.
|
||||||
|
- [x] Provider-specific Microsoft detail is explicitly nested under provider-owned profile or context disclosure instead of shared contract truth.
|
||||||
|
- [x] Scope boundaries, assumptions, risks, and deferred adjacent candidates remain explicit.
|
||||||
|
|
||||||
|
## Repo Truth Anchoring
|
||||||
|
|
||||||
|
- [x] The package reflects that `ProviderConnection` already belongs to `ManagedEnvironment` via `managed_environment_id`.
|
||||||
|
- [x] The package reflects that current platform-core seams still leak Microsoft semantics through `tenantContext` and `target_scope.entra_tenant_id`.
|
||||||
|
- [x] The package reflects that `config/provider_boundaries.php` already classifies provider identity, connection resolution, and operation-start seams as platform-core follow-up hotspots.
|
||||||
|
- [x] The package reflects that `ProviderConnectionResource` exists with `Create`, `View`, and `Edit` pages and remains non-globally-searchable.
|
||||||
|
- [x] The package reflects that `ManagedTenantOnboardingWizard` and managed-environment related-context seams already reuse provider summaries and therefore need one summary contract.
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] Filament v5 and Livewire v4 expectations remain explicit across the package.
|
||||||
|
- [x] Provider registration location remains explicit as `apps/platform/bootstrap/providers.php`.
|
||||||
|
- [x] `ProviderConnectionResource` global-search status and touched searchable-surface notes remain explicit.
|
||||||
|
- [x] Destructive action confirmation and authorization expectations remain explicit for touched provider-connection mutations.
|
||||||
|
- [x] The unchanged asset strategy and deployment note remain explicit.
|
||||||
|
- [x] The test strategy and minimal proving commands are explicit and aligned across artifacts.
|
||||||
|
- [x] The Candidate Selection Gate still explains why `281` is chosen now and why `282`-`287` are deferred.
|
||||||
|
- [x] The Completed-Spec Guardrail still keeps `279` and `280` separate from this package.
|
||||||
|
|
||||||
|
## Artifact Alignment
|
||||||
|
|
||||||
|
- [x] `research.md` records the same bounded extraction decisions reflected in `plan.md`.
|
||||||
|
- [x] `data-model.md` models the same neutral `target_scope`, provider-context, effective-client-identity, onboarding, and run-context contracts reflected in the plan and contract file.
|
||||||
|
- [x] `quickstart.md` uses the same bounded reviewer flow and proof commands as `plan.md`.
|
||||||
|
- [x] `contracts/provider-connection-scope.logical.openapi.yaml` models the same shared summary, identity-resolution, provider-profile, onboarding-readiness, and operation-start contracts described in the plan.
|
||||||
|
- [x] Canonical proof commands match across `spec.md`, `plan.md`, and `quickstart.md`.
|
||||||
|
|
||||||
|
## Test Governance
|
||||||
|
|
||||||
|
- [x] Planned proof stays bounded to focused feature coverage, one browser smoke, and the existing guard concept for Microsoft-shaped shared-contract leaks.
|
||||||
|
- [x] No new heavy-governance family or broad browser matrix is introduced.
|
||||||
|
- [x] Workspace, managed-environment, provider-connection, and optional credential fixture cost is acknowledged instead of hidden.
|
||||||
|
- [x] Reviewer handoff includes exact minimal validation commands and concrete stop questions.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Reviewed against `.specify/memory/constitution.md`, `specs/279-workspace-managed-environment-core/spec.md`, `specs/280-workspace-tenancy-environment-routing/spec.md`, `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolution.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Services/Providers/CredentialManager.php`, `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`, `apps/platform/app/Services/Providers/ProviderGateway.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php`, `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`, and `apps/platform/config/provider_boundaries.php` on 2026-05-07.
|
||||||
|
- No application implementation, test execution, or runtime validation was performed while preparing this package.
|
||||||
|
|
||||||
|
## Review Outcome
|
||||||
|
|
||||||
|
- **Outcome class**: `implementation-ready`
|
||||||
|
- **Workflow outcome**: `keep`
|
||||||
|
- **Test-governance outcome**: `keep`
|
||||||
|
- **Reason**: The package turns the ready spec into an implementation-ready plan set that neutralizes shared provider-connection and target-scope contracts, confines Microsoft profile detail to provider-owned seams, and keeps all adjacent routing, taxonomy, RBAC, copy, and quality-gate work deferred.
|
||||||
@ -0,0 +1,438 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: TenantPilot Admin - Provider Connection Scope & Profile Contract (Conceptual)
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Conceptual shared-contract artifact for Spec 281.
|
||||||
|
|
||||||
|
This package keeps the existing provider-connection persistence model and
|
||||||
|
operator surfaces, but makes the shared provider-connection target-scope,
|
||||||
|
identity-resolution, onboarding-summary, and operation-start context shape
|
||||||
|
implementable without guessing at field ownership.
|
||||||
|
|
||||||
|
These paths model logical surfaces and shared contracts, not a promise of
|
||||||
|
new public route families. Public route ownership remains on the existing
|
||||||
|
Filament resource and pages, with adjacent routing work deferred to Spec 280.
|
||||||
|
servers:
|
||||||
|
- url: /logical/provider-connections
|
||||||
|
paths:
|
||||||
|
/connections/{connection}/surface-summary:
|
||||||
|
get:
|
||||||
|
summary: Resolve the shared provider-connection summary contract
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ConnectionIdentifier'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Shared provider-connection summary resolved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionSurfaceSummaryView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
x-contract-rules:
|
||||||
|
- Shared `target_scope` must use `ProviderTargetScope`.
|
||||||
|
- Provider-specific Microsoft detail remains nested under provider context or profile metadata.
|
||||||
|
/connections/{connection}/identity-resolution:
|
||||||
|
get:
|
||||||
|
summary: Resolve the shared provider identity-result contract
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ConnectionIdentifier'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Provider identity resolved or blocked with a neutral shared contract
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderIdentityResolutionView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
x-contract-rules:
|
||||||
|
- The shared contract centers on `target_scope`, effective client identity, credential source, and blocked reason.
|
||||||
|
- Provider-specific authority or redirect detail stays nested in `provider_context`.
|
||||||
|
/connections/{connection}/provider-profile:
|
||||||
|
get:
|
||||||
|
summary: Resolve provider-owned profile and contextual disclosure
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ConnectionIdentifier'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Provider-owned profile disclosure resolved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderProfileDisclosureView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
x-provider-owned: true
|
||||||
|
/onboarding/provider-connections/{connection}/readiness:
|
||||||
|
get:
|
||||||
|
summary: Resolve the onboarding readiness summary for an existing provider connection
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ConnectionIdentifier'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Onboarding readiness payload resolved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OnboardingProviderConnectionReadinessView'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
x-contract-rules:
|
||||||
|
- Onboarding must reuse the same `ProviderConnectionSurfaceSummaryView` contract as the provider-connections resource.
|
||||||
|
/provider-operations/{operationType}/start:
|
||||||
|
post:
|
||||||
|
summary: Start or block a provider operation using neutral shared target-scope context
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/OperationType'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderOperationStartRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Existing active run reused or scope marked busy
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderOperationStartResult'
|
||||||
|
'202':
|
||||||
|
description: New operation run queued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderOperationStartResult'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'422':
|
||||||
|
description: Operation start blocked with a recorded run and neutral shared target-scope context
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderOperationStartResult'
|
||||||
|
x-contract-rules:
|
||||||
|
- Shared run context must use `ProviderOperationRunContext.target_scope` instead of `target_scope.entra_tenant_id`.
|
||||||
|
- Provider-specific detail needed for follow-up belongs in `provider_context.details`.
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
ConnectionIdentifier:
|
||||||
|
name: connection
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
OperationType:
|
||||||
|
name: operationType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
Forbidden:
|
||||||
|
description: Actor is in scope but lacks the required provider capability.
|
||||||
|
NotFound:
|
||||||
|
description: Provider connection or managed-environment scope is not visible to the actor.
|
||||||
|
schemas:
|
||||||
|
ProviderTargetScope:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- scope_kind
|
||||||
|
- scope_identifier
|
||||||
|
- scope_display_name
|
||||||
|
- shared_label
|
||||||
|
- shared_help_text
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
scope_kind:
|
||||||
|
type: string
|
||||||
|
enum: [tenant]
|
||||||
|
scope_identifier:
|
||||||
|
type: string
|
||||||
|
scope_display_name:
|
||||||
|
type: string
|
||||||
|
shared_label:
|
||||||
|
type: string
|
||||||
|
shared_help_text:
|
||||||
|
type: string
|
||||||
|
ProviderContextDetail:
|
||||||
|
type: object
|
||||||
|
description: Extensible provider-owned detail item used for profile, consent, required-permissions, domain, portal, audit, or troubleshooting disclosure.
|
||||||
|
required:
|
||||||
|
- detail_key
|
||||||
|
- detail_label
|
||||||
|
- detail_value
|
||||||
|
- visibility
|
||||||
|
properties:
|
||||||
|
detail_key:
|
||||||
|
type: string
|
||||||
|
description: Stable provider-owned detail key such as microsoft_tenant_id, authority_tenant, redirect_uri, admin_consent_url, required_permissions_url, portal_domain, or portal_link.
|
||||||
|
detail_label:
|
||||||
|
type: string
|
||||||
|
detail_value:
|
||||||
|
type: string
|
||||||
|
visibility:
|
||||||
|
type: string
|
||||||
|
enum: [contextual_only, audit_only, troubleshooting_only]
|
||||||
|
ProviderContext:
|
||||||
|
type: object
|
||||||
|
description: Provider-owned nested context wrapper reused by UI summaries, audit metadata, and provider-operation follow-up surfaces.
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- details
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
details:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ProviderContextDetail'
|
||||||
|
EffectiveClientIdentity:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- credential_source
|
||||||
|
properties:
|
||||||
|
client_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
credential_source:
|
||||||
|
type: string
|
||||||
|
BlockedReason:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- reason_code
|
||||||
|
properties:
|
||||||
|
reason_code:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ProviderConnectionSurfaceSummaryView:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- target_scope
|
||||||
|
- consent_state
|
||||||
|
- verification_state
|
||||||
|
- readiness_summary
|
||||||
|
- target_scope_summary
|
||||||
|
- provider_context
|
||||||
|
- is_enabled
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
target_scope:
|
||||||
|
$ref: '#/components/schemas/ProviderTargetScope'
|
||||||
|
consent_state:
|
||||||
|
type: string
|
||||||
|
verification_state:
|
||||||
|
type: string
|
||||||
|
readiness_summary:
|
||||||
|
type: string
|
||||||
|
target_scope_summary:
|
||||||
|
type: string
|
||||||
|
provider_context:
|
||||||
|
$ref: '#/components/schemas/ProviderContext'
|
||||||
|
contextual_identity_line:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
is_enabled:
|
||||||
|
type: boolean
|
||||||
|
ProviderIdentityResolutionView:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- resolved
|
||||||
|
- connection_type
|
||||||
|
- effective_client_identity
|
||||||
|
- provider_context
|
||||||
|
properties:
|
||||||
|
resolved:
|
||||||
|
type: boolean
|
||||||
|
connection_type:
|
||||||
|
type: string
|
||||||
|
target_scope:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProviderTargetScope'
|
||||||
|
nullable: true
|
||||||
|
effective_client_identity:
|
||||||
|
$ref: '#/components/schemas/EffectiveClientIdentity'
|
||||||
|
blocked_reason:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BlockedReason'
|
||||||
|
nullable: true
|
||||||
|
provider_context:
|
||||||
|
$ref: '#/components/schemas/ProviderContext'
|
||||||
|
ProviderProfileDisclosureView:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- target_scope
|
||||||
|
- provider_context
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
target_scope:
|
||||||
|
$ref: '#/components/schemas/ProviderTargetScope'
|
||||||
|
provider_context:
|
||||||
|
$ref: '#/components/schemas/ProviderContext'
|
||||||
|
PermissionOverview:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- overall
|
||||||
|
- counts
|
||||||
|
- freshness
|
||||||
|
- missing_permissions
|
||||||
|
properties:
|
||||||
|
overall:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
counts:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
freshness:
|
||||||
|
type: object
|
||||||
|
required: [last_refreshed_at, is_stale]
|
||||||
|
properties:
|
||||||
|
last_refreshed_at:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
is_stale:
|
||||||
|
type: boolean
|
||||||
|
missing_permissions:
|
||||||
|
type: object
|
||||||
|
required: [application, delegated]
|
||||||
|
properties:
|
||||||
|
application:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
delegated:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required_permissions_url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
OnboardingProviderConnectionReadinessView:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- provider_connection_id
|
||||||
|
- provider_summary
|
||||||
|
- permission_overview
|
||||||
|
properties:
|
||||||
|
provider_connection_id:
|
||||||
|
type: integer
|
||||||
|
provider_summary:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionSurfaceSummaryView'
|
||||||
|
permission_overview:
|
||||||
|
$ref: '#/components/schemas/PermissionOverview'
|
||||||
|
ProviderBindingContext:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- binding_status
|
||||||
|
- handler_notes
|
||||||
|
- exception_notes
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
binding_status:
|
||||||
|
type: string
|
||||||
|
handler_notes:
|
||||||
|
type: string
|
||||||
|
exception_notes:
|
||||||
|
type: string
|
||||||
|
ProviderOperationRunContext:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- module
|
||||||
|
- provider_binding
|
||||||
|
- target_scope
|
||||||
|
properties:
|
||||||
|
execution_authority_mode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
required_capability:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
module:
|
||||||
|
type: string
|
||||||
|
provider_binding:
|
||||||
|
$ref: '#/components/schemas/ProviderBindingContext'
|
||||||
|
provider_connection_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
target_scope:
|
||||||
|
$ref: '#/components/schemas/ProviderTargetScope'
|
||||||
|
provider_context:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProviderContext'
|
||||||
|
nullable: true
|
||||||
|
OperationRunReference:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- type
|
||||||
|
- status
|
||||||
|
- context
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
outcome:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
context:
|
||||||
|
$ref: '#/components/schemas/ProviderOperationRunContext'
|
||||||
|
ProviderOperationStartRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- managed_environment_id
|
||||||
|
properties:
|
||||||
|
managed_environment_id:
|
||||||
|
type: integer
|
||||||
|
provider_connection_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
execution_authority_mode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
extra_context:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
ProviderOperationStartResult:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- result
|
||||||
|
- run
|
||||||
|
properties:
|
||||||
|
result:
|
||||||
|
type: string
|
||||||
|
enum: [started, deduped, scope_busy, blocked]
|
||||||
|
run:
|
||||||
|
$ref: '#/components/schemas/OperationRunReference'
|
||||||
|
blocked_reason:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BlockedReason'
|
||||||
|
nullable: true
|
||||||
232
specs/281-provider-connection-scope/data-model.md
Normal file
232
specs/281-provider-connection-scope/data-model.md
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
# Data Model: Provider Connection Scope & Microsoft Profile Extraction
|
||||||
|
|
||||||
|
**Date**: 2026-05-07
|
||||||
|
**Branch**: `281-provider-connection-scope`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This slice introduces no new persistence. It keeps the existing provider-connection, credential, and run records intact and instead standardizes the derived runtime contracts that platform-core seams expose to UI, audit, and provider-operation flows.
|
||||||
|
|
||||||
|
## Persisted Truth Unchanged
|
||||||
|
|
||||||
|
- `ProviderConnection` remains the workspace-owned, managed-environment-scoped binding record.
|
||||||
|
- `ProviderCredential` remains the optional credential record attached to one `ProviderConnection`.
|
||||||
|
- `OperationRun` remains execution truth and keeps its current identity and lifecycle ownership.
|
||||||
|
- `config/provider_boundaries.php` remains the single source for provider-owned versus platform-core seam classification.
|
||||||
|
- No new table, registry, provider-profile entity, enum family, or taxonomy is introduced.
|
||||||
|
|
||||||
|
## Derived Runtime Contracts
|
||||||
|
|
||||||
|
### 1. Provider Connection Record
|
||||||
|
|
||||||
|
**Persistence**: existing database row
|
||||||
|
**Owner**: `ProviderConnection`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | int | yes | Existing record key |
|
||||||
|
| `workspace_id` | int | yes | Existing workspace boundary |
|
||||||
|
| `managed_environment_id` | int | yes | Existing managed-environment boundary; already the canonical scope anchor |
|
||||||
|
| `provider` | string | yes | Current provider key (`microsoft` today) |
|
||||||
|
| `display_name` | string | yes | Operator-visible connection label |
|
||||||
|
| `connection_type` | enum | yes | Existing `platform` or `dedicated` connection type |
|
||||||
|
| `is_default` | bool | yes | Existing default-binding flag |
|
||||||
|
| `is_enabled` | bool | yes | Existing enablement flag |
|
||||||
|
| `consent_status` | enum | yes | Existing consent state |
|
||||||
|
| `verification_status` | enum | yes | Existing verification state |
|
||||||
|
| `entra_tenant_id` | string | yes | Existing provider-owned persisted identifier; not the shared contract key after this slice |
|
||||||
|
| `metadata` | array | no | Existing legacy-identity and provider-owned metadata |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- `managed_environment_id` remains the persisted scope anchor; the stale candidate move is not reopened.
|
||||||
|
- `entra_tenant_id` may remain a provider-owned stored value, but platform-core consumers must read the normalized `target_scope` contract instead of exposing this column name as shared truth.
|
||||||
|
- `metadata` remains derived/provider-owned detail and must not become a second canonical shared scope contract.
|
||||||
|
|
||||||
|
### 2. Shared Target Scope Descriptor
|
||||||
|
|
||||||
|
**Persistence**: derived
|
||||||
|
**Owner**: `ProviderConnectionTargetScopeDescriptor`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `provider` | string | yes | Shared provider key |
|
||||||
|
| `scope_kind` | string | yes | Current release supports `tenant` only |
|
||||||
|
| `scope_identifier` | string | yes | Neutral scope identifier used across platform-core seams |
|
||||||
|
| `scope_display_name` | string | yes | Operator-facing name for the scope |
|
||||||
|
| `shared_label` | string | yes | Current shared label, `Target scope` |
|
||||||
|
| `shared_help_text` | string | yes | Current shared help text |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- This is the canonical shared `target_scope` object for connection resolution, identity resolution, audit metadata, surface summaries, onboarding readiness, and provider-operation start context.
|
||||||
|
- Shared `target_scope` payloads must not require `entra_tenant_id` as a top-level key.
|
||||||
|
- `scope_kind` remains the current `tenant` constant; this slice does not add new scope-state machinery.
|
||||||
|
|
||||||
|
### 3. Provider Context
|
||||||
|
|
||||||
|
**Persistence**: derived
|
||||||
|
**Owner**: `ProviderIdentityContextMetadata`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `provider` | string | yes | Provider key for the disclosed context |
|
||||||
|
| `details` | list<object> | yes | Ordered provider-owned detail items for profile, consent, audit, or troubleshooting disclosure |
|
||||||
|
|
||||||
|
**Nested `provider_context.details` item**
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `detail_key` | string | yes | Stable provider-owned detail key such as `microsoft_tenant_id`, `admin_consent_url`, `required_permissions_url`, or `portal_domain` |
|
||||||
|
| `detail_label` | string | yes | Operator/support label |
|
||||||
|
| `detail_value` | string | yes | Current provider-owned value |
|
||||||
|
| `visibility` | string | yes | `contextual_only`, `audit_only`, or `troubleshooting_only` |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- `provider_context` is the canonical nested provider-owned wrapper carried by shared identity, summary, onboarding, audit, and run-context contracts.
|
||||||
|
- Current Microsoft details include `microsoft_tenant_id`, `authority_tenant`, `redirect_uri`, consent links, required-permissions guidance, domains, and portal/profile links.
|
||||||
|
- The detail set is intentionally provider-owned and extensible; this slice does not freeze provider context to a three-key catalog.
|
||||||
|
- These values may appear in nested provider profile/context blocks or audit metadata, but they do not replace the shared `target_scope` descriptor.
|
||||||
|
|
||||||
|
### 4. Provider Identity Resolution Contract
|
||||||
|
|
||||||
|
**Persistence**: derived
|
||||||
|
**Owner**: `ProviderIdentityResolution`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `resolved` | bool | yes | Shared resolved/blocked status |
|
||||||
|
| `connection_type` | string | yes | Existing connection-type truth |
|
||||||
|
| `target_scope` | object | no | Canonical shared `target_scope` descriptor |
|
||||||
|
| `effective_client_identity.client_id` | string | no | Neutral shared client identity when resolved |
|
||||||
|
| `effective_client_identity.credential_source` | string | yes | Shared credential source (`platform_config`, dedicated source, legacy source) |
|
||||||
|
| `blocked_reason.reason_code` | string | no | Existing provider reason code when blocked |
|
||||||
|
| `blocked_reason.message` | string | no | Operator-facing blocked reason message |
|
||||||
|
| `provider_context` | object | yes | Nested provider-owned context wrapper with `provider` and ordered `details` |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- The shared contract center is `target_scope`, effective client identity, credential source, and blocked reason.
|
||||||
|
- Legacy `tenantContext` is an implementation concern that should be absorbed into nested provider context or authority handling, not left as the primary shared contract field name.
|
||||||
|
- `clientSecret` remains runtime-only and is excluded from surface and audit contracts.
|
||||||
|
- Blocked results still return `target_scope` when it can be normalized, so surfaces keep one consistent summary even on failure.
|
||||||
|
|
||||||
|
### 5. Provider Connection Surface Summary
|
||||||
|
|
||||||
|
**Persistence**: derived
|
||||||
|
**Owner**: `ProviderConnectionSurfaceSummary`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `provider` | string | yes | Shared provider key |
|
||||||
|
| `target_scope` | object | yes | Canonical shared descriptor |
|
||||||
|
| `consent_state` | string | yes | Existing consent status value |
|
||||||
|
| `verification_state` | string | yes | Existing verification status value |
|
||||||
|
| `readiness_summary` | string | yes | Existing operator summary |
|
||||||
|
| `target_scope_summary` | string | yes | Shared rendered summary for UI surfaces |
|
||||||
|
| `provider_context` | object | yes | Nested provider-owned context wrapper with `provider` and ordered `details` |
|
||||||
|
| `contextual_identity_line` | string | no | Optional condensed display line derived from nested provider context |
|
||||||
|
| `is_enabled` | bool | yes | Existing enablement state for display logic |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context summaries must all reuse this contract.
|
||||||
|
- Default-visible content remains summary-first: target scope, readiness, consent, and verification.
|
||||||
|
- Provider-specific detail is secondary and derived from nested provider context detail only.
|
||||||
|
- Invalid target scope falls back to an explicit review-needed summary instead of leaking raw provider fields back into shared UI.
|
||||||
|
|
||||||
|
### 6. Onboarding Provider-Connection Readiness View
|
||||||
|
|
||||||
|
**Persistence**: derived
|
||||||
|
**Owner**: `ManagedTenantOnboardingWizard`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `provider_connection_id` | int | yes | Selected or referenced connection |
|
||||||
|
| `provider_summary` | object | yes | Reused `ProviderConnectionSurfaceSummary` payload |
|
||||||
|
| `permission_overview` | object | yes | Existing required-permissions overview including nested provider-owned `required_permissions_url` guidance |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- Onboarding must reuse the same `provider_summary.target_scope` and `target_scope_summary` contract as the provider-connections resource.
|
||||||
|
- Supporting verification and permission links remain secondary and stay nested under `permission_overview.required_permissions_url` or equivalent provider-owned guidance fields.
|
||||||
|
- No onboarding-only target-scope wording or fallback structure is introduced.
|
||||||
|
|
||||||
|
### 7. Provider Operation Run Context
|
||||||
|
|
||||||
|
**Persistence**: derived run context in existing `OperationRun` rows
|
||||||
|
**Owner**: `ProviderOperationStartGate`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `execution_authority_mode` | string | yes | Existing execution-authority contract |
|
||||||
|
| `required_capability` | string | no | Existing capability contract |
|
||||||
|
| `provider` | string | yes | Shared provider key |
|
||||||
|
| `module` | string | yes | Existing provider-operation module |
|
||||||
|
| `provider_binding` | object | yes | Existing registry binding metadata |
|
||||||
|
| `provider_connection_id` | int | no | Existing binding identity when present |
|
||||||
|
| `target_scope` | object | yes | Canonical shared descriptor |
|
||||||
|
| `provider_context` | object | no | Nested provider-owned details when required for follow-up |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- Started and blocked runs must use the same neutral shared `target_scope` schema.
|
||||||
|
- Shared run context must stop writing `target_scope.entra_tenant_id` as the primary contract.
|
||||||
|
- Provider-specific fields needed for follow-up or troubleshooting move to nested `provider_context` or equivalent provider-owned metadata.
|
||||||
|
- Current dedupe identity remains `provider_connection_id` plus existing identity inputs; this slice does not redefine run identity semantics.
|
||||||
|
|
||||||
|
### 8. Credential Scope Validation Invariant
|
||||||
|
|
||||||
|
**Persistence**: derived runtime validation only
|
||||||
|
**Owner**: `CredentialManager`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `provider_connection_id` | int | yes | Existing credential owner |
|
||||||
|
| `payload.client_id` | string | yes | Existing credential field |
|
||||||
|
| `payload.client_secret` | string | yes | Existing credential field |
|
||||||
|
| `payload.scope_assertion` | mixed | no | Existing payload assertion if present today |
|
||||||
|
| `normalized_target_scope_identifier` | string | yes | Derived from canonical shared descriptor |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- No provider-credential schema change is introduced.
|
||||||
|
- If a payload carries a scope assertion, validation should compare it to the normalized target-scope identifier rather than leaking raw provider-column names into the platform-core error contract.
|
||||||
|
- Neutral mismatch wording belongs in the shared seam; provider-specific values remain nested metadata only.
|
||||||
|
|
||||||
|
### 9. Provider Boundary Review Record
|
||||||
|
|
||||||
|
**Persistence**: config-driven
|
||||||
|
**Owner**: `config/provider_boundaries.php`
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `seam_key` | string | yes | Boundary seam identifier |
|
||||||
|
| `owner` | string | yes | `platform_core` or `provider_owned` |
|
||||||
|
| `neutral_terms` | list<string> | yes | Shared vocabulary allowed at the seam |
|
||||||
|
| `retained_provider_semantics` | list<string> | yes | Documented provider-specific exceptions |
|
||||||
|
| `follow_up_action` | string | yes | Existing review follow-up rule |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- `provider.connection_resolution`, `provider.identity_resolution`, and `provider.operation_start_gate` remain platform-core seams and must carry only the documented provider-specific exceptions.
|
||||||
|
- `provider.gateway_runtime` remains provider-owned.
|
||||||
|
- `config/provider_boundaries.php` stays the single review record for this classification; the slice does not create a new taxonomy.
|
||||||
|
|
||||||
|
## Contract Flow
|
||||||
|
|
||||||
|
1. `ProviderConnection` is loaded inside its current workspace plus managed-environment scope.
|
||||||
|
2. `ProviderConnectionTargetScopeNormalizer` derives the canonical shared `target_scope` descriptor and the nested `provider_context` wrapper.
|
||||||
|
3. `ProviderConnectionResolver` validates enablement, consent, and supported binding using the normalized `target_scope` contract.
|
||||||
|
4. `ProviderIdentityResolver` emits one `ProviderIdentityResolution` result centered on target scope, effective client identity, and nested provider context.
|
||||||
|
5. `ProviderConnectionSurfaceSummary` renders the same summary contract for the provider-connections resource, onboarding, and related-context surfaces.
|
||||||
|
6. `ProviderOperationStartGate` records the same neutral `target_scope` contract into `OperationRun` context while nesting any provider-only detail under provider context.
|
||||||
|
7. Provider-owned consumers such as admin-consent URL shaping and Graph runtime mapping read the nested provider context they need without re-promoting those values into shared platform-core vocabulary.
|
||||||
|
|
||||||
|
## Deferred Boundaries
|
||||||
|
|
||||||
|
- No new provider implementation is introduced.
|
||||||
|
- No provider-profile table, registry, package engine, or artifact taxonomy is introduced.
|
||||||
|
- No routing work from Spec `280` is absorbed.
|
||||||
|
- No RBAC redesign, copy-neutralization, or cutover quality-gate work from Specs `282` through `287` is introduced.
|
||||||
288
specs/281-provider-connection-scope/plan.md
Normal file
288
specs/281-provider-connection-scope/plan.md
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
# Implementation Plan: Provider Connection Scope & Microsoft Profile Extraction
|
||||||
|
|
||||||
|
**Branch**: `281-provider-connection-scope` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `specs/281-provider-connection-scope/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Prepare the next reserved provider-boundary slice that keeps `ProviderConnection` as the existing managed-environment-scoped binding record but extracts one provider-neutral target-scope and effective-client-identity contract across the current platform-core seams. The narrow implementation path reuses `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResolver`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderOperationStartGate`, `CredentialManager`, `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and `config/provider_boundaries.php` while explicitly deferring Specs `282` through `287`.
|
||||||
|
|
||||||
|
This plan stays intentionally bounded. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, no `tenant_id` to `managed_environment_id` migration is reintroduced, no provider-profile table or registry appears, no routing cutover from Spec `280` is absorbed, no RBAC redesign or taxonomy work is introduced, and no compatibility alias such as shared `tenantContext` or shared `target_scope.entra_tenant_id` survives as platform-core truth.
|
||||||
|
|
||||||
|
## Inherited Baseline / Explicit Delta
|
||||||
|
|
||||||
|
### Inherited baseline
|
||||||
|
|
||||||
|
- Spec `279` already completed the core managed-environment cutover and is completed historical context only.
|
||||||
|
- Spec `280` already prepared the adjacent workspace-first routing shell and remains separate prepared context only.
|
||||||
|
- `apps/platform/app/Models/ProviderConnection.php` already anchors provider connections by `workspace_id` plus `managed_environment_id`; no `tenant_id` migration remains for this feature.
|
||||||
|
- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` already exists with `List`, `Create`, `View`, and `Edit` pages, remains `protected static bool $isGloballySearchable = false;`, and already groups mutating actions behind confirmation-protected Filament actions.
|
||||||
|
- `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php` and `apps/platform/config/provider_boundaries.php` already classify `provider.identity_resolution`, `provider.connection_resolution`, and `provider.operation_start_gate` as platform-core seams with retained Microsoft-specific exceptions that still need follow-through.
|
||||||
|
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `ProviderConnectionTargetScopeNormalizer.php`, and `ProviderConnectionSurfaceSummary.php` already expose neutral field names such as `scope_kind`, `scope_identifier`, and `scope_display_name`, but they still derive those values directly from `ProviderConnection::entra_tenant_id` and still emit Microsoft contextual detail.
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `ProviderIdentityResolution.php`, and `PlatformProviderIdentityResolver.php` still use `tenantContext` as the shared identity field name even though the same path already exposes `credentialSource`, `effectiveClientId`, `targetScope`, and contextual detail lists.
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` still writes `target_scope.entra_tenant_id` for both started and blocked run context.
|
||||||
|
- `apps/platform/app/Services/Providers/CredentialManager.php` still validates credential payload scope against `entra_tenant_id` and still reports a Microsoft-shaped mismatch message from a platform-core seam.
|
||||||
|
- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Filament/Resources/TenantResource.php` already reuse provider-connection summary and related-context seams, so they are the existing consumers that must converge on one contract rather than new surfaces that need to be invented.
|
||||||
|
|
||||||
|
### Explicit delta in this plan
|
||||||
|
|
||||||
|
- Keep `ProviderConnection`, `ProviderCredential`, `OperationRun`, and current route ownership intact; the slice is contract extraction only.
|
||||||
|
- Make `ProviderConnectionTargetScopeDescriptor` plus `ProviderConnectionTargetScopeNormalizer` the canonical shared `target_scope` contract across connection resolution, identity resolution, audit metadata, provider-operation start context, resource summaries, onboarding readiness, and related-context summaries.
|
||||||
|
- Reshape `ProviderIdentityResolution` around neutral effective-client-identity and target-scope language while confining Microsoft-specific tenant, authority, redirect, and consent semantics to nested provider-owned profile or context detail.
|
||||||
|
- Update `ProviderOperationStartGate` and the associated audit metadata path so shared `OperationRun` context no longer depends on `target_scope.entra_tenant_id` as the primary contract.
|
||||||
|
- Keep `ProviderConnectionSurfaceSummary` as the one summary adapter reused by `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context provider summaries.
|
||||||
|
- Keep provider-owned Microsoft behavior such as admin-consent URL shaping, Graph runtime option mapping, and Microsoft profile disclosure explicit and secondary instead of turning them into platform-core vocabulary.
|
||||||
|
- Keep all provider registration, asset handling, routing, RBAC, taxonomy, capability-registry, and broader copy-neutralization work deferred to adjacent specs.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12.52
|
||||||
|
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing provider-boundary config and target-scope helper seams
|
||||||
|
**Storage**: PostgreSQL, no new persistence or schema change in this slice
|
||||||
|
**Testing**: Pest feature tests, one Pest browser smoke, and focused guard coverage for Microsoft-shaped shared-contract regressions
|
||||||
|
**Validation Lanes**: fast-feedback, confidence, browser
|
||||||
|
**Target Platform**: Laravel monolith in `apps/platform`
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: preserve current provider-connection resource, onboarding readiness, and provider-operation start responsiveness while changing only contract shaping and shared summaries; no new remote inline work or new asset load path
|
||||||
|
**Constraints**: no new table or persisted provider-profile truth, no registry or capability engine, no reintroduction of the stale `tenant_id` migration candidate, no routing cutover absorption from Spec `280`, provider registration stays in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, destructive actions stay confirmation-protected, asset strategy remains unchanged, and preparation work must stay spec-only
|
||||||
|
**Scale/Scope**: one provider-neutral target-scope and identity contract across the existing provider resolution, summary, onboarding, and operation-start seams for the single current Microsoft provider implementation
|
||||||
|
|
||||||
|
## Likely Affected Repo Surfaces
|
||||||
|
|
||||||
|
- `apps/platform/app/Models/ProviderConnection.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderConnectionResolution.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`
|
||||||
|
- `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`
|
||||||
|
- `apps/platform/app/Services/Providers/CredentialManager.php`
|
||||||
|
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`
|
||||||
|
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`
|
||||||
|
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`
|
||||||
|
- `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.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/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||||
|
- `apps/platform/app/Support/Providers/RequiredPermissionsLinks.php`
|
||||||
|
- `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderGateway.php`
|
||||||
|
- `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`
|
||||||
|
- `apps/platform/config/provider_boundaries.php`
|
||||||
|
- feature, browser, and guard coverage under `apps/platform/tests/Feature` and `apps/platform/tests/Browser`
|
||||||
|
|
||||||
|
## Filament v5 / Provider Resource Notes
|
||||||
|
|
||||||
|
- **Livewire v4.0+ compliance**: all touched Filament work remains on Filament v5 with Livewire v4; the slice changes summary and contract shaping only.
|
||||||
|
- **Provider registration location**: provider registration stays in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
|
||||||
|
- **Global search rule**: `ProviderConnectionResource` already remains `isGloballySearchable = false` and still has `View` plus `Edit` pages. No new searchable resource is introduced by this slice. If a touched searchable consumer such as `TenantResource` continues to surface provider summaries, it must keep its existing valid view destination unchanged.
|
||||||
|
- **Destructive actions**: the touched provider-connection mutations already use `Actions\Action::make(...)->action(...)` with `->requiresConfirmation()` and server-side capability checks. The relevant confirmation-protected actions currently include `set_default`, `enable_dedicated_override`, `rotate_dedicated_credential`, `delete_dedicated_credential`, `revert_to_platform`, `enable_connection`, and `disable_connection`; this slice preserves that behavior.
|
||||||
|
- **Asset strategy**: no new panel or shared asset registration is planned. Deployment guidance remains unchanged: `cd apps/platform && php artisan filament:assets` is only needed when registered assets change, and this slice adds none.
|
||||||
|
|
||||||
|
## Neutral Target-Scope & Identity Contract Fit
|
||||||
|
|
||||||
|
- Treat `ProviderConnectionTargetScopeDescriptor` as the canonical shared `target_scope` object with `provider`, `scope_kind`, `scope_identifier`, `scope_display_name`, `shared_label`, and `shared_help_text`.
|
||||||
|
- Treat `ProviderIdentityResolution` as the canonical shared identity-result object for `resolved`, `connection_type`, `effective client identity`, blocked reason, target scope, and provider-context details.
|
||||||
|
- Treat `ProviderConnectionSurfaceSummary` as the only shared summary adapter for provider-connection list/detail surfaces, onboarding readiness, and managed-environment related context.
|
||||||
|
- Treat `ProviderOperationStartGate` as the only platform-core seam allowed to shape `OperationRun` context for provider-start flows.
|
||||||
|
- Treat `ProviderIdentityContextMetadata` as provider-owned disclosure metadata. Microsoft-specific items such as `microsoft_tenant_id`, `authority_tenant`, and `redirect_uri` stay nested there or in provider-owned profile blocks instead of becoming new shared top-level contract keys.
|
||||||
|
- Treat `AdminConsentUrlFactory` and `ProviderGateway` as downstream provider-owned consumers that must adapt to the neutral shared identity result if field names change.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: mixed native Filament resource plus existing custom onboarding wizard
|
||||||
|
- **Shared-family relevance**: provider-connections resource family, managed-environment related context, onboarding readiness/provider summary, shared provider-operation feedback
|
||||||
|
- **State layers in scope**: page, detail, modal, Livewire state, related-context summaries
|
||||||
|
- **Audience modes in scope**: operator-MSP, support-platform
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: decision-first shared target-scope summary, diagnostics-second readiness and blocked-reason detail, provider-raw/profile detail third and explicitly nested
|
||||||
|
- **Raw/support gating plan**: capability-gated or contextual-only provider profile detail using existing provider-context metadata visibility
|
||||||
|
- **One-primary-action / duplicate-truth control**: `ProviderConnectionSurfaceSummary` stays the one shared summary contract so provider-connections, onboarding, and related context do not invent parallel identity stories
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory until shared seams stop exposing Microsoft field names as primary contract truth
|
||||||
|
- **Special surface test profiles**: standard-native-filament, workflow-hub, global-context-shell
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke
|
||||||
|
- **Exception path and spread control**: none; the slice removes Microsoft-shaped spread from platform-core seams instead of adding a new exception
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**: provider connection resolution, identity resolution, target-scope normalization, provider-connection surface summaries, onboarding readiness summaries, provider-operation start context, provider boundary catalog, and provider-owned consent/runtime consumers
|
||||||
|
- **Shared abstractions reused**: `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResolution`, `ProviderIdentityResolution`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, and the existing `ProviderConnectionResource` surface contract
|
||||||
|
- **New abstraction introduced? why?**: none planned; the existing target-scope and identity seams are sufficient once their field ownership and vocabulary are corrected
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: the repo already has the right seams and summary objects. What is insufficient is the Microsoft-shaped payload and naming that still flows through them.
|
||||||
|
- **Bounded deviation / spread control**: Microsoft-specific consent URL, authority, redirect, and profile identifiers may remain in provider-owned nested metadata and provider-runtime seams only; they must not remain the primary shared contract fields
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes, start and blocked context only
|
||||||
|
- **Central contract reused**: `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, and the existing `OperationRunService` lifecycle path
|
||||||
|
- **Delegated UX behaviors**: queued or blocked start-state messaging, dedupe-or-scope-busy outcomes, run creation identity, and start-result presentation remain on the shared provider-operation path
|
||||||
|
- **Surface-owned behavior kept local**: `ProviderConnectionResource` and `ManagedTenantOnboardingWizard` continue to own only connection selection, initiation inputs, and follow-up link placement
|
||||||
|
- **Queued DB-notification policy**: `N/A` - unchanged shared policy
|
||||||
|
- **Terminal notification path**: existing central lifecycle mechanism
|
||||||
|
- **Exception path**: none
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Provider-owned seams**: Microsoft admin-consent URL shaping, Microsoft portal or profile disclosure, Graph runtime option mapping, and provider-context detail visibility
|
||||||
|
- **Platform-core seams**: `provider.connection_resolution`, `provider.identity_resolution`, `provider.operation_start_gate`, target-scope descriptor/normalizer, surface-summary reuse, and shared run or audit context
|
||||||
|
- **Neutral platform terms / contracts preserved**: `provider connection`, `target scope`, `scope kind`, `scope identifier`, `scope display name`, `effective client identity`, `credential source`, `provider profile`, `provider context`, `workspace`, and `managed environment`
|
||||||
|
- **Retained provider-specific semantics and why**: Microsoft tenant identifiers, authority tenant, redirect URI, required consent flow, and Graph runtime details remain necessary for the current provider implementation. They stay nested under provider-owned metadata or provider-runtime seams rather than becoming platform-core contract keys.
|
||||||
|
- **Bounded extraction or follow-up path**: Specs `282` through `287` remain the follow-up path for artifact retargeting, capability registry, taxonomy, RBAC, copy neutralization, and cutover quality gates
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before implementation begins and again after the design artifacts are complete.*
|
||||||
|
|
||||||
|
- Inventory-first / snapshot truth: PASS. The slice changes shared provider-boundary contracts only; inventory and snapshot truth are unchanged.
|
||||||
|
- Read/write separation: PASS. No new write workflow is introduced; existing provider operations keep their current confirmation, audit, and run-observability path.
|
||||||
|
- Graph contract path: PASS. No Graph endpoint or contract-registry work is added; provider-owned Graph option shaping remains behind the current provider runtime seam.
|
||||||
|
- Deterministic capabilities: PASS. Capability requirements remain the current registry-backed provider capabilities.
|
||||||
|
- RBAC-UX plane separation: PASS. `/admin` versus `/system` remains unchanged.
|
||||||
|
- Workspace isolation: PASS. `ProviderConnection` remains workspace-owned and environment-scoped; no access-boundary change is planned.
|
||||||
|
- Managed-environment isolation: PASS. Provider-connection resolution and onboarding continue to require the current managed-environment boundary.
|
||||||
|
- Destructive action discipline: PASS by preservation. Existing confirmation-protected provider-connection mutations remain confirmation-protected and server-authorized.
|
||||||
|
- Global search safety: PASS. `ProviderConnectionResource` already remains non-globally-searchable and keeps valid view/edit pages.
|
||||||
|
- OperationRun / Ops-UX: PASS. The slice reuses the shared provider-operation start path and changes only the context contract it records.
|
||||||
|
- Data minimization: PASS. No new persistence or provider-profile table is introduced; provider-specific detail remains nested metadata.
|
||||||
|
- Test governance: PASS. Proof remains bounded to focused feature coverage, one browser smoke, and one leak-guard family.
|
||||||
|
- Proportionality / no premature abstraction: PASS. The plan reuses the current seams instead of introducing a new provider framework, registry, or profile entity.
|
||||||
|
- Persisted truth / behavioral state: PASS. No new table, enum family, or taxonomy is introduced.
|
||||||
|
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing resource and wizard surfaces remain the first path and keep one shared summary adapter.
|
||||||
|
- Provider boundary: PASS with implementation condition. Platform-core seams must stop treating `tenantContext` and `target_scope.entra_tenant_id` as shared truth; Microsoft detail remains explicit and nested.
|
||||||
|
|
||||||
|
**Gate evaluation**: PASS.
|
||||||
|
|
||||||
|
**Post-design re-check**: PASS while `research.md`, `data-model.md`, `quickstart.md`, `contracts/provider-connection-scope.logical.openapi.yaml`, and `checklists/requirements.md` stay aligned on the same neutral `target_scope`, effective-client-identity, provider-profile disclosure, and proving-command contract.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Feature, Browser
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence, browser
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: the change is a shared-contract extraction across resolvers, start-gate context, and two operator-facing summary consumers. Focused feature coverage proves the contract and guard behavior, and one browser smoke proves the resource plus onboarding surfaces still tell the same connection story in the live shell.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace, managed environment, provider connection, optional provider credential, and operation-run fixtures without broadening shared defaults
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; any new provider fixture helper should remain opt-in and feature-local
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none beyond one slice-specific browser smoke already justified in the spec
|
||||||
|
- **Surface-class relief / special coverage rule**: standard-native-filament relief for the resource and one workflow-hub browser proof for onboarding continuity
|
||||||
|
- **Closing validation and reviewer handoff**: rerun the exact commands above, verify that shared contract outputs now use neutral `target_scope` keys, verify that `ProviderIdentityResolution` no longer uses a shared `tenantContext` field name for platform-core truth, verify that `ProviderOperationStartGate` stopped writing shared `target_scope.entra_tenant_id`, verify that Microsoft detail appears only inside provider-owned context or profile blocks, verify that `ProviderConnectionResource` stays non-globally-searchable with View/Edit pages intact, confirm the existing destructive actions still require confirmation plus server authorization, and confirm provider registration plus asset strategy remain unchanged
|
||||||
|
- **Budget / baseline / trend follow-up**: contained feature-local increase only
|
||||||
|
- **Review-stop questions**: did a new provider-profile table or registry appear, did shared seams keep `tenantContext` or `target_scope.entra_tenant_id` as primary truth, did provider-specific profile detail escape nested metadata, did onboarding and provider-connections diverge into separate summary contracts, did the slice absorb deferred Specs `282` through `287`
|
||||||
|
- **Escalation path**: `reject-or-split` if implementation introduces new persistence, shared compatibility aliases, routing cutover work, RBAC redesign, or provider-framework machinery
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: the adjacent follow-up work is already reserved as Specs `282` through `287`; 281 only needs the bounded contract extraction itself
|
||||||
|
|
||||||
|
## Review Checklist Status
|
||||||
|
|
||||||
|
- **Review checklist artifact**: `checklists/requirements.md`
|
||||||
|
- **Review outcome class**: `implementation-ready`
|
||||||
|
- **Workflow outcome**: `keep`
|
||||||
|
- **Test-governance outcome**: `keep`
|
||||||
|
- **Escalation rule**: if implementation preserves shared Microsoft-shaped contract fields, adds new provider persistence, or absorbs routing, registry, taxonomy, RBAC, or copy-neutralization work, flip the workflow outcome to `split` or `reject-or-split`
|
||||||
|
|
||||||
|
## Rollout Considerations
|
||||||
|
|
||||||
|
- Land target-scope extraction, identity-result reshaping, surface-summary convergence, and provider-operation context updates as one bounded implementation slice so platform-core truth changes atomically.
|
||||||
|
- Retarget provider-owned consumers such as admin-consent URL shaping only after the shared identity result is finalized, not as a separate second contract.
|
||||||
|
- Keep managed-environment related-context summaries and onboarding readiness on the same shared summary adapter before polishing any additional copy.
|
||||||
|
- Keep Spec `280` route work separate; `281` should consume the existing surfaces and not redefine their route ownership.
|
||||||
|
|
||||||
|
## Risk Controls
|
||||||
|
|
||||||
|
- Reject any implementation that reintroduces the stale `tenant_id` to `managed_environment_id` candidate work on `ProviderConnection`.
|
||||||
|
- Reject any implementation that adds a provider-profile table, registry, capability engine, package abstraction, or artifact taxonomy.
|
||||||
|
- Reject any implementation that leaves shared `tenantContext` or shared `target_scope.entra_tenant_id` in platform-core contracts as compatibility aliases.
|
||||||
|
- Reject any implementation that duplicates summary logic outside `ProviderConnectionSurfaceSummary` for provider-connections, onboarding, or related context.
|
||||||
|
- Reject any implementation that moves provider registration out of `apps/platform/bootstrap/providers.php` or adds new Filament asset registration for this slice.
|
||||||
|
|
||||||
|
## Research & Design Outputs
|
||||||
|
|
||||||
|
- `research.md` records the bounded extraction decisions for persisted truth, shared target-scope ownership, shared identity-result ownership, provider-owned Microsoft profile disclosure, start-gate context shape, and proof strategy.
|
||||||
|
- `data-model.md` captures the unchanged persisted truth plus the derived `target_scope`, effective-client-identity, provider-context, surface-summary, onboarding-readiness, and run-context contracts.
|
||||||
|
- `quickstart.md` gives reviewers the bounded package review flow and the exact proving commands.
|
||||||
|
- `contracts/provider-connection-scope.logical.openapi.yaml` captures the conceptual summary, provider-profile, onboarding-readiness, and operation-start contracts with the neutral shared `target_scope` schema.
|
||||||
|
- `checklists/requirements.md` records package readiness, boundedness, and outcome state.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/281-provider-connection-scope/
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── provider-connection-scope.logical.openapi.yaml
|
||||||
|
├── data-model.md
|
||||||
|
├── plan.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── research.md
|
||||||
|
├── spec.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (expected implementation surfaces)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── Pages/
|
||||||
|
│ │ │ └── Workspaces/
|
||||||
|
│ │ │ └── ManagedTenantOnboardingWizard.php
|
||||||
|
│ │ └── Resources/
|
||||||
|
│ │ ├── ProviderConnectionResource.php
|
||||||
|
│ │ ├── TenantResource.php
|
||||||
|
│ │ └── ProviderConnectionResource/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── ProviderConnection.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ └── Providers/
|
||||||
|
│ │ ├── AdminConsentUrlFactory.php
|
||||||
|
│ │ ├── CredentialManager.php
|
||||||
|
│ │ ├── PlatformProviderIdentityResolver.php
|
||||||
|
│ │ ├── ProviderConnectionResolution.php
|
||||||
|
│ │ ├── ProviderConnectionResolver.php
|
||||||
|
│ │ ├── ProviderGateway.php
|
||||||
|
│ │ ├── ProviderIdentityResolution.php
|
||||||
|
│ │ ├── ProviderIdentityResolver.php
|
||||||
|
│ │ └── ProviderOperationStartGate.php
|
||||||
|
│ └── Support/
|
||||||
|
│ └── Providers/
|
||||||
|
│ ├── Boundary/
|
||||||
|
│ │ └── ProviderBoundaryCatalog.php
|
||||||
|
│ └── TargetScope/
|
||||||
|
│ ├── ProviderConnectionSurfaceSummary.php
|
||||||
|
│ ├── ProviderConnectionTargetScopeDescriptor.php
|
||||||
|
│ ├── ProviderConnectionTargetScopeNormalizer.php
|
||||||
|
│ └── ProviderIdentityContextMetadata.php
|
||||||
|
├── bootstrap/
|
||||||
|
│ └── providers.php
|
||||||
|
└── config/
|
||||||
|
└── provider_boundaries.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure decision**: keep the documentation package self-contained under `specs/281-provider-connection-scope/`; later implementation should update the existing provider resolution, summary, onboarding, and operation-start seams in place instead of introducing a parallel provider-contract subsystem.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violation or bloat exception is introduced by the plan. The slice removes Microsoft-shaped leakage from existing platform-core seams and adds no new persistence, abstraction family, taxonomy, or framework.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: the platform-core provider boundary still forces operators, audit consumers, and operation-run context to rely on Microsoft-shaped identity and scope fields even though provider connections are already modeled as managed-environment-scoped records.
|
||||||
|
- **Existing structure is insufficient because**: the current repo already has target-scope and summary helpers, but the remaining shared field names and run-context keys still encode Microsoft semantics as generic truth.
|
||||||
|
- **Narrowest correct implementation**: reuse the existing descriptor, normalizer, identity result, resource summary, onboarding readiness, and start-gate seams while replacing the Microsoft-shaped shared contract fields with neutral `target_scope` and effective-client-identity outputs.
|
||||||
|
- **Ownership cost created**: focused contract, summary, and proof updates across the listed provider seams and their tests.
|
||||||
|
- **Alternative intentionally rejected**: a new provider-profile table, provider registry, capability engine, or broader multi-provider identity framework, because none are required by current-release truth.
|
||||||
|
- **Release truth**: current-release truth; this is a bounded extraction of an already-verified hotspot, not future-provider platform preparation.
|
||||||
41
specs/281-provider-connection-scope/quickstart.md
Normal file
41
specs/281-provider-connection-scope/quickstart.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Quickstart: Provider Connection Scope & Microsoft Profile Extraction
|
||||||
|
|
||||||
|
## Reviewer Flow
|
||||||
|
|
||||||
|
1. Read [spec.md](./spec.md), [plan.md](./plan.md), [research.md](./research.md), and [data-model.md](./data-model.md) together.
|
||||||
|
2. Confirm the package stays on reserved slot `281` only and treats Spec `279` as completed context and Spec `280` as adjacent prepared routing work only.
|
||||||
|
3. Confirm the verified current repo truth: `ProviderConnection` already uses `managed_environment_id`, `ProviderConnectionResource` is already non-globally-searchable with `Create`, `View`, and `Edit` pages, and `config/provider_boundaries.php` already classifies `provider.connection_resolution`, `provider.identity_resolution`, and `provider.operation_start_gate` as platform-core seams.
|
||||||
|
4. Confirm the package does not introduce a provider-profile table, registry, capability engine, or any other speculative multi-provider framework.
|
||||||
|
5. Confirm the canonical shared target-scope contract is explicit and unchanged across artifacts: `provider`, `scope_kind`, `scope_identifier`, `scope_display_name`, `shared_label`, and `shared_help_text`.
|
||||||
|
6. Confirm the shared identity-result contract is explicit and neutral: target scope, effective client identity, credential source, blocked reason, and nested provider-context details; shared `tenantContext` is not treated as the long-term platform-core field name.
|
||||||
|
7. Confirm the planned `OperationRun` context rewrite is explicit: shared `target_scope` becomes the neutral descriptor shape and provider-specific Microsoft detail moves to nested provider context or profile metadata rather than staying at `target_scope.entra_tenant_id`.
|
||||||
|
8. Confirm `ProviderConnectionSurfaceSummary` remains the single summary contract for `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context provider summaries.
|
||||||
|
9. Confirm Filament guardrails remain explicit: Filament v5 stays on Livewire v4, provider registration stays in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, destructive provider-connection mutations stay confirmation-protected and server-authorized, and asset strategy remains unchanged.
|
||||||
|
10. Confirm no application implementation, test execution, or non-spec artifact modification is included in this prep package.
|
||||||
|
|
||||||
|
## Planned Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)
|
||||||
|
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)
|
||||||
|
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review Questions
|
||||||
|
|
||||||
|
- Does the package stay bounded to contract extraction across the current provider seams rather than drifting into a provider framework or profile table?
|
||||||
|
- Does the package explicitly avoid reintroducing the stale `provider_connections.tenant_id` move?
|
||||||
|
- Does the shared `target_scope` schema stay neutral everywhere in the package instead of carrying `entra_tenant_id` as shared truth?
|
||||||
|
- Does the identity-result contract make effective client identity and credential source explicit while keeping provider-specific Microsoft detail nested?
|
||||||
|
- Do provider-connections, onboarding, and related-context summaries all reuse one shared summary adapter?
|
||||||
|
- Does the start-gate contract clearly replace shared `target_scope.entra_tenant_id` with a neutral `target_scope` object plus nested provider context?
|
||||||
|
- Does `ProviderConnectionResource` stay non-globally-searchable while preserving `View` and `Edit` page destinations and the current confirmation-protected actions?
|
||||||
|
- Does the package keep Filament on Livewire v4, keep provider registration in `apps/platform/bootstrap/providers.php`, and avoid new asset or deployment steps?
|
||||||
|
- Do Specs `282` through `287` remain explicitly deferred rather than silently absorbed?
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This prep package changes only planning artifacts under `specs/281-provider-connection-scope/`.
|
||||||
|
- No application implementation, tests, or runtime validation were executed while preparing the package.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user