feat: implement workspace and tenant closure lifecycle (#337)
## Summary - add explicit workspace closure and tenant removal lifecycle truth with a bounded `WorkspaceLifecycleService` - surface closure and removal posture across admin/system pages, chooser recovery, and canonical historical viewers - block new review-pack and operation starts for closed workspaces or removed tenants while preserving memberships, audit, and history - add focused Pest coverage plus the Spec 292 artifacts for the implemented slice ## Testing - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Directory/ViewWorkspaceClosureTest.php tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php` - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - manual integrated-browser smoke for admin tenant remove/restore plus chooser recovery and system workspace close/reopen Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #337
This commit is contained in:
parent
670c46dedd
commit
210508db9d
@ -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.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -147,6 +148,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 +165,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 +200,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 +407,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 +748,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 +862,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 +994,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 +1068,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 +1126,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 +2480,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 +2900,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 +2912,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 +2969,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 +3217,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();
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
&& (
|
&& (
|
||||||
|
|||||||
@ -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'),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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,7 +40,29 @@
|
|||||||
<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>
|
</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>
|
||||||
|
|
||||||
|
@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">
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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,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');
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
302
specs/292-workspace-tenant-closure/plan.md
Normal file
302
specs/292-workspace-tenant-closure/plan.md
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
# Implementation Plan: Workspace & Tenant Closure Lifecycle v1
|
||||||
|
|
||||||
|
**Branch**: `292-workspace-tenant-closure` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `specs/292-workspace-tenant-closure/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Prepare one bounded lifecycle runtime follow-through on top of the Spec 262 taxonomy foundation plus the already-real tenant operability and commercial lifecycle seams. The narrow implementation path is to add explicit workspace closed truth on `Workspace`, explicit removed-from-workspace truth on `ManagedEnvironment`, keep commercial suspension as a separate shared gate, and reuse the existing system directory, admin resource, chooser, and canonical historical viewer surfaces instead of opening a new workflow console.
|
||||||
|
|
||||||
|
This slice stays deliberately narrow. Filament remains v5 on Livewire v4, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no asset registration change is expected. The plan does not reopen the broader taxonomy, does not implement purge or export-before-delete, does not add payment-provider or billing-portal behavior, and does not add a new `OperationRun` family.
|
||||||
|
|
||||||
|
## Inherited Baseline / Explicit Delta
|
||||||
|
|
||||||
|
### Inherited baseline
|
||||||
|
|
||||||
|
- `Workspace` already carries `archived_at`, and `WorkspaceContext::isWorkspaceSelectable()` already uses that archived truth to prevent invalid workspace selection.
|
||||||
|
- `ManagedEnvironment` already carries tenant lifecycle truth, and `TenantLifecycle` plus `TenantOperabilityService` already distinguish active context from onboarding or archive semantics.
|
||||||
|
- `WorkspaceCommercialLifecycleResolver` already maps commercial truth into `SUSPENDED_READ_ONLY` versus normal workspace posture for high-impact actions.
|
||||||
|
- `EnsureWorkspaceSelected`, `EnsureFilamentTenantSelected`, and `DenyNonMemberTenantAccess` already provide the current context-recovery and deny-as-not-found seams.
|
||||||
|
- `WorkspaceMembershipManager`, `TenantMembershipManager`, `WorkspaceAuditLogger`, and current tenant audit paths already provide bounded audit-safe mutation seams.
|
||||||
|
- Existing admin and system surfaces already exist at `ViewWorkspace`, `ViewTenant`, `ViewRun`, `ChooseWorkspace`, `ChooseTenant`, `WorkspaceResource`, `TenantResource`, and `WorkspaceSettings`.
|
||||||
|
|
||||||
|
### Explicit delta in this plan
|
||||||
|
|
||||||
|
- Add explicit closure truth to `Workspace` using bounded persisted fields for closed posture, actor, and reason.
|
||||||
|
- Add explicit removed-from-workspace truth to `ManagedEnvironment` using bounded persisted fields for removal posture, actor, and reason.
|
||||||
|
- Introduce one bounded `WorkspaceLifecycleService` to orchestrate close/reopen and remove/restore without opening a generic lifecycle framework.
|
||||||
|
- Keep `archived`, `suspended read-only`, `closed`, and `removed from workspace` as four distinct meanings with separate badges, copy, and blocked-action explanations.
|
||||||
|
- Update chooser recovery, tenant-context legitimacy, and canonical historical viewers so historical records remain readable while invalid active context is cleared explicitly.
|
||||||
|
- Keep all new lifecycle mutations inside current admin and system surfaces and current audit infrastructure.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `WorkspaceContext`, `TenantOperabilityService`, `WorkspaceCommercialLifecycleResolver`, current audit infrastructure, current Filament admin and system pages
|
||||||
|
**Storage**: PostgreSQL via targeted new lifecycle fields on existing `workspaces` and `managed_environments` tables plus existing audit and history tables
|
||||||
|
**Testing**: Pest v4 focused `Feature` coverage
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Laravel monolith in `apps/platform` across the existing admin and system Filament panels
|
||||||
|
**Project Type**: Web application (Laravel monolith with Filament panels)
|
||||||
|
**Performance Goals**: DB-only lifecycle gating, no new queue family, no new Graph calls, no new browser-only proof requirement
|
||||||
|
**Constraints**: no purge, no export-before-delete, no payment-provider or billing workflow, no new global-search resource, no new panel, no lifecycle engine, no asset-registration change
|
||||||
|
**Scale/Scope**: 2 existing persisted records gain bounded lifecycle truth, 1 bounded service seam, 6 existing operator surfaces, and focused feature-test extensions
|
||||||
|
|
||||||
|
## Likely Affected Repo Surfaces
|
||||||
|
|
||||||
|
- `apps/platform/app/Models/Workspace.php`
|
||||||
|
- `apps/platform/app/Models/ManagedEnvironment.php`
|
||||||
|
- `apps/platform/database/migrations/*_add_workspace_closure_fields.php`
|
||||||
|
- `apps/platform/database/migrations/*_add_managed_environment_workspace_removal_fields.php`
|
||||||
|
- `apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php`
|
||||||
|
- `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
|
||||||
|
- `apps/platform/app/Services/Tenants/TenantOperabilityService.php`
|
||||||
|
- `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`
|
||||||
|
- `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`
|
||||||
|
- `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`
|
||||||
|
- `apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php`
|
||||||
|
- `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`
|
||||||
|
- `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`
|
||||||
|
- `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/ChooseWorkspace.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/ChooseTenant.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||||
|
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||||
|
- current tenant audit logging path where tenant membership and lifecycle actions are recorded
|
||||||
|
- focused feature tests under `apps/platform/tests/Feature/System/`, `apps/platform/tests/Feature/Filament/Resources/`, and `apps/platform/tests/Feature/Filament/Pages/`
|
||||||
|
|
||||||
|
## Lifecycle Truth Fit
|
||||||
|
|
||||||
|
- Treat workspace closure as a dedicated workspace-owned lifecycle posture, not as archive and not as commercial suspension.
|
||||||
|
- Treat tenant removal from workspace as a dedicated workspace-owned posture on the tenant record, not as tenant archive and not as provider absence.
|
||||||
|
- Keep commercial suspension in `WorkspaceCommercialLifecycleResolver` as the shared source of `SUSPENDED_READ_ONLY`; closure logic must compose with it, not replace it.
|
||||||
|
- Preserve current tenant lifecycle and provider-missing semantics; the feature only adds workspace-governed closure and removal truth.
|
||||||
|
- Preserve historical readability:
|
||||||
|
- closed workspace -> not selectable as current workspace, read-only historical inspection remains available to entitled actors
|
||||||
|
- removed tenant -> not selectable as current tenant, canonical historical inspection remains available to entitled actors
|
||||||
|
- suspended read-only -> current workspace remains inspectable and current tenant may remain visible, but mutations and starts stay blocked per existing commercial rules
|
||||||
|
|
||||||
|
## Data & Query Fit
|
||||||
|
|
||||||
|
- Extend `workspaces` with bounded closure fields only. The expected shape is a timestamp, actor reference, and reason text rather than a broad lifecycle ledger.
|
||||||
|
- Extend `managed_environments` with bounded removed-from-workspace fields only. The expected shape mirrors the workspace closure truth and remains reversible.
|
||||||
|
- Add only the indexes needed to filter active versus closed workspaces and active versus removed tenants.
|
||||||
|
- Keep lifecycle truth on the primary records instead of introducing a new history table. Audit remains the historical source for who changed posture and when.
|
||||||
|
- Preserve existing ownership rules: workspaces stay workspace-owned, tenants stay workspace-owned, and no cross-workspace migration or copy is introduced.
|
||||||
|
|
||||||
|
## UI / Filament & Livewire Fit
|
||||||
|
|
||||||
|
- Existing operator-facing surfaces remain native Filament surfaces under Livewire v4; this slice must stay inside those surfaces.
|
||||||
|
- `ViewWorkspace` in the system plane keeps one dominant action: `Close workspace` or `Reopen workspace`.
|
||||||
|
- Admin workspace detail gains read-only posture summary only; no second mutation plane for closure appears on `/admin`.
|
||||||
|
- Managed tenant list keeps row click as the inspect model and moves remove or restore into `More`; managed tenant detail may expose `Remove tenant` or `Restore tenant` as the dominant lifecycle action.
|
||||||
|
- Chooser surfaces remain chooser surfaces. They gain explicit recovery messaging, not a new workflow.
|
||||||
|
- Canonical historical viewers gain posture badges or supporting text only; they do not become mutation surfaces.
|
||||||
|
- Confirmation modals and success or error notifications on the in-scope action surfaces must reuse the canonical verbs `Close workspace`, `Reopen workspace`, `Remove tenant`, and `Restore tenant`.
|
||||||
|
- No new globally searchable Filament resource is introduced, so there is no new global-search Edit or View page requirement to satisfy.
|
||||||
|
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, and no new asset strategy is planned. If future shared assets ever become necessary, deployment remains the normal `cd apps/platform && php artisan filament:assets` path.
|
||||||
|
|
||||||
|
## RBAC / Policy Fit
|
||||||
|
|
||||||
|
- Workspace and tenant membership remain the isolation boundaries.
|
||||||
|
- Wrong-plane actors and non-members stay `404`; in-scope actors missing capability stay `403`.
|
||||||
|
- Closure and removal actions remain server-side authorized and confirmation-protected.
|
||||||
|
- Platform closure uses the current platform workspace-governance seam or the narrowest bounded extension of it. Tenant removal uses the current workspace owner or equivalent tenant-governance seam.
|
||||||
|
- The exact write-side enforcement seam is `apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php`, and the in-scope mutation entry points are the system workspace detail actions in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and the tenant lifecycle actions in `apps/platform/app/Filament/Resources/TenantResource.php`.
|
||||||
|
- Unsafe close or remove attempts must preserve current guard failures with a clear operator-facing reason and no partial lifecycle mutation.
|
||||||
|
- Closure or removal posture must never substitute for authorization. A historically readable record still requires entitlement.
|
||||||
|
- Tenant-context routes under `/admin/t/{tenant}/...` must reject removed tenants as active context targets while preserving canonical viewers that are not tenant-context routes.
|
||||||
|
|
||||||
|
## Audit / Logging Fit
|
||||||
|
|
||||||
|
- Every close/reopen and remove/restore mutation must write an audit event with actor, old posture, new posture, timestamp, and reason.
|
||||||
|
- Existing audit infrastructure must be reused rather than opening a new lifecycle audit subsystem.
|
||||||
|
- Historical viewers should surface closure or removal context through current summary and linked audit rather than duplicating the full audit payload.
|
||||||
|
- Blocked active-context starts caused by closure or removal do not need a new audit family in this slice unless existing blocked-action logging already applies.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament
|
||||||
|
- **Shared-family relevance**: status messaging, chooser recovery, detail actions, canonical historical viewers, audit-backed lifecycle copy
|
||||||
|
- **State layers in scope**: shell, page, detail
|
||||||
|
- **Audience modes in scope**: operator-MSP, support-platform
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
|
||||||
|
- **Raw/support gating plan**: raw audit and low-level identifiers remain secondary and platform-only where applicable
|
||||||
|
- **One-primary-action / duplicate-truth control**: system workspace detail keeps one dominant closure action; tenant detail keeps one dominant remove/restore action; chooser recovery surfaces keep one next step
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory now; hard-stop candidate if implementation adds a second closure plane or local lifecycle vocabulary
|
||||||
|
- **Special surface test profiles**: standard-native-filament, global-context-shell, shared-detail-family
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract
|
||||||
|
- **Exception path and spread control**: none planned; any lifecycle dashboard, broad workbench, or browser-heavy proof demand is out-of-scope drift
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**: `WorkspaceContext`, `TenantOperabilityService`, `WorkspaceCommercialLifecycleResolver`, chooser pages, system directory pages, admin workspace and tenant resources, audit logging, and canonical historical viewers
|
||||||
|
- **Shared abstractions reused**: current context manager, current commercial lifecycle resolver, current tenant operability service, current audit infrastructure, `BadgeCatalog` and `BadgeRenderer`, current Filament action-surface patterns
|
||||||
|
- **New abstraction introduced? why?**: one bounded `WorkspaceLifecycleService`, because close/reopen and remove/restore each require coordinated record mutation, audit logging, and shared blocked-action consequences across multiple surfaces
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: current services already decide selectability, commercial blocking, and audit shape, but no existing service owns the explicit closure or removal mutation path or the shared write-side behavior for it
|
||||||
|
- **Bounded deviation / spread control**: no second closure orchestration helper is allowed outside the bounded service path
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes, by reuse and blocking only
|
||||||
|
- **Central contract reused**: current shared `OperationRun` start UX and current canonical monitoring pages remain authoritative
|
||||||
|
- **Delegated UX behaviors**: blocked starts in closed workspaces or removed-tenant contexts must fail before enqueue on `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php`; existing run links and canonical historical viewers remain unchanged for already-created runs
|
||||||
|
- **Surface-owned behavior kept local**: close/reopen and remove/restore input collection plus impact summaries only
|
||||||
|
- **Queued DB-notification policy**: unchanged
|
||||||
|
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||||
|
- **Exception path**: none
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: no
|
||||||
|
- **Provider-owned seams**: none in this slice
|
||||||
|
- **Platform-core seams**: workspace closure truth, tenant removal truth, chooser recovery, and historical record readability
|
||||||
|
- **Neutral platform terms / contracts preserved**: `workspace`, `tenant`, `closed`, `removed from workspace`, `suspended read-only`, `history`
|
||||||
|
- **Retained provider-specific semantics and why**: none
|
||||||
|
- **Bounded extraction or follow-up path**: provider-missing and other provider lifecycle work stays in separate follow-up specs
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before implementation begins and again before merge.*
|
||||||
|
|
||||||
|
- Inventory-first / snapshot truth: PASS. No inventory or snapshot source is reinterpreted.
|
||||||
|
- Read/write separation: PASS. The only new writes are explicit lifecycle mutations with confirmation, audit, and test requirements.
|
||||||
|
- Graph contract path: PASS. No Graph calls are introduced.
|
||||||
|
- Deterministic capabilities: PASS. Existing capability registries remain authoritative.
|
||||||
|
- Workspace and tenant isolation: PASS. Existing 404 and 403 rules remain and are reinforced.
|
||||||
|
- RBAC-UX plane separation: PASS. System owns workspace closure mutation; admin remains read-only for workspace closure and bounded for tenant removal inside the current workspace.
|
||||||
|
- Destructive action discipline: PASS. Close/reopen and remove/restore are destructive-like and must remain confirmation-protected.
|
||||||
|
- Global search safety: PASS. No new searchable resource is introduced.
|
||||||
|
- OperationRun / Ops-UX: PASS by reuse only. No new run family is added, and blocked starts stay pre-enqueue.
|
||||||
|
- Data minimization: PASS. Only bounded lifecycle truth fields are added on existing records.
|
||||||
|
- Test governance: PASS. Focused feature lanes are the narrowest honest proof.
|
||||||
|
- Proportionality / no premature abstraction: PASS. One bounded service is the narrowest write-side seam; no generic lifecycle framework is planned.
|
||||||
|
- Persisted truth: PASS. Closure and removal posture are real product truth with independent lifecycle and audit need.
|
||||||
|
- Behavioral state: PASS. Closed and removed-from-workspace change chooser behavior, mutation legality, and historical-view posture.
|
||||||
|
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing chooser, audit, badge, and Filament surfaces remain the shared path.
|
||||||
|
- Provider boundary: PASS. No provider-specific semantics are added.
|
||||||
|
- Filament / Laravel planning contract: PASS. Filament stays v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, no globally searchable resource is added, and no asset registration change is planned.
|
||||||
|
|
||||||
|
**Gate evaluation**: PASS.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Feature` for system workspace governance, admin read-only posture, managed-tenant remove/restore, chooser recovery, and canonical historical viewer legitimacy
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: the slice is about integrated route, page, capability, chooser, and audit behavior; unit-only proof would miss the real lifecycle consequences
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Directory/ViewWorkspaceClosureTest.php tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; setup needs explicit workspace and tenant membership plus lifecycle posture states, but no new provider, browser, or heavy defaults
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none planned
|
||||||
|
- **Surface-class relief / special coverage rule**: standard-native-filament relief for the system and admin detail surfaces; global-context-shell coverage for chooser recovery; shared-detail-family coverage for historical viewers
|
||||||
|
- **Closing validation and reviewer handoff**: reviewers should rely on the exact commands above, verify that no second mutation plane or lifecycle vocabulary appeared, and confirm that blocked starts stay pre-enqueue while historical viewers stay readable
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local feature-test increase
|
||||||
|
- **Review-stop questions**: did the slice collapse closure into archive or suspension, did it break canonical history viewers, did it add a second mutation plane, and did it widen into purge/export/billing scope
|
||||||
|
- **Escalation path**: `document-in-feature` for contained naming or surface drift; `reject-or-split` if the slice widens into broader offboarding, purge, or billing workflows
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: the remaining adjacent work is already known and separately named; this slice is the bounded workspace and tenant closure follow-through itself
|
||||||
|
- **Test-governance outcome**: keep
|
||||||
|
|
||||||
|
## Rollout Considerations
|
||||||
|
|
||||||
|
- Land persistence and the bounded service seam first, then workspace closure surfaces plus chooser recovery, then tenant removal surfaces plus canonical historical viewer polish.
|
||||||
|
- Keep admin workspace detail read-only for closure mutation throughout the slice.
|
||||||
|
- Keep commercial suspension readable and separate from closure throughout the rollout.
|
||||||
|
- Keep no asset changes, no provider registration changes, no new panel work, and no global-search work.
|
||||||
|
|
||||||
|
## Risk Controls
|
||||||
|
|
||||||
|
- Reject any implementation that reuses `archived_at` or commercial suspension as the new closure truth.
|
||||||
|
- Reject any implementation that deletes memberships, tenants, workspaces, or historical records as part of close/reopen or remove/restore.
|
||||||
|
- Reject any implementation that makes `/admin` a second workspace-closure mutation plane.
|
||||||
|
- Reject any implementation that turns the slice into export, purge, payment-provider, or portal work.
|
||||||
|
- Reject browser-heavy proof as the default validation lane.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/292-workspace-tenant-closure/
|
||||||
|
├── plan.md
|
||||||
|
├── spec.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (expected implementation surfaces)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── Pages/
|
||||||
|
│ │ │ ├── ChooseTenant.php
|
||||||
|
│ │ │ ├── ChooseWorkspace.php
|
||||||
|
│ │ │ └── Settings/
|
||||||
|
│ │ │ └── WorkspaceSettings.php
|
||||||
|
│ │ ├── Resources/
|
||||||
|
│ │ │ ├── TenantResource.php
|
||||||
|
│ │ │ └── Workspaces/
|
||||||
|
│ │ │ ├── WorkspaceResource.php
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewWorkspace.php
|
||||||
|
│ │ └── System/Pages/
|
||||||
|
│ │ ├── Directory/
|
||||||
|
│ │ │ ├── ViewTenant.php
|
||||||
|
│ │ │ └── ViewWorkspace.php
|
||||||
|
│ │ └── Ops/
|
||||||
|
│ │ └── ViewRun.php
|
||||||
|
│ ├── Http/Middleware/
|
||||||
|
│ │ └── EnsureWorkspaceSelected.php
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ ├── ManagedEnvironment.php
|
||||||
|
│ │ └── Workspace.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ ├── Audit/
|
||||||
|
│ │ │ └── WorkspaceAuditLogger.php
|
||||||
|
│ │ ├── Entitlements/
|
||||||
|
│ │ │ └── WorkspaceCommercialLifecycleResolver.php
|
||||||
|
│ │ ├── Tenants/
|
||||||
|
│ │ │ └── TenantOperabilityService.php
|
||||||
|
│ │ └── Workspaces/
|
||||||
|
│ │ └── WorkspaceLifecycleService.php
|
||||||
|
│ └── Support/
|
||||||
|
│ ├── Middleware/
|
||||||
|
│ │ ├── DenyNonMemberTenantAccess.php
|
||||||
|
│ │ └── EnsureFilamentTenantSelected.php
|
||||||
|
│ └── Workspaces/
|
||||||
|
│ └── WorkspaceContext.php
|
||||||
|
├── database/
|
||||||
|
│ └── migrations/
|
||||||
|
└── tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Filament/
|
||||||
|
└── System/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the slice inside the existing Laravel monolith and current Filament admin plus system surfaces. Add only targeted lifecycle fields, one bounded service, and focused feature tests.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| Bounded lifecycle write service | Close/reopen and remove/restore need coordinated mutation, audit, and shared blocked-action behavior | Controller or page-local closures would duplicate audit and lifecycle consequences across multiple surfaces |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Operators cannot deliberately close a workspace or remove a tenant from a workspace while keeping history readable and posture explicit.
|
||||||
|
- **Existing structure is insufficient because**: archive and commercial suspension represent different meanings and cannot safely absorb closure or removal truth.
|
||||||
|
- **Narrowest correct implementation**: add bounded fields on existing records, route writes through one bounded service, and reuse current chooser, audit, and Filament surfaces.
|
||||||
|
- **Ownership cost created**: migrations, one service, shared status and chooser updates, and feature tests.
|
||||||
|
- **Alternative intentionally rejected**: generic lifecycle engine, new closure history tables, or overloading archive and suspension.
|
||||||
|
- **Release truth**: current-release truth.
|
||||||
370
specs/292-workspace-tenant-closure/spec.md
Normal file
370
specs/292-workspace-tenant-closure/spec.md
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
# Feature Specification: Workspace & Tenant Closure Lifecycle v1
|
||||||
|
|
||||||
|
**Feature Branch**: `292-workspace-tenant-closure`
|
||||||
|
**Created**: 2026-05-07
|
||||||
|
**Status**: Approved for implementation
|
||||||
|
**Input**: User description: "Promote the lifecycle taxonomy follow-through into one bounded runtime slice that closes workspaces, removes tenants from workspaces, and defines explicit suspended read-only versus closed behavior without introducing purge flows."
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: TenantPilot already has partial lifecycle truth for tenant operability, archived workspaces, and subscription-driven suspended read-only posture, but it still has no explicit product truth for deliberately closing a workspace or removing a tenant from a workspace while preserving history.
|
||||||
|
- **Today's failure**: Operators can archive records or lose access implicitly through membership or context drift, but they cannot make an explicit, auditable closure decision. `archived`, `suspended read-only`, selector invalidation, and historical access are still separate behaviors rather than one bounded closure contract.
|
||||||
|
- **User-visible improvement**: Platform and workspace operators can close or reopen workspaces and remove or restore tenants with explicit confirmation, clear read-only behavior, consistent chooser recovery, and preserved historical viewers instead of relying on implicit 404s or overloaded archive semantics.
|
||||||
|
- **Smallest enterprise-capable version**: Add explicit closure truth on `Workspace`, explicit removed-from-workspace truth on `ManagedEnvironment`, reuse existing admin and system Filament surfaces, keep history readable, block new mutations and starts where required, and stop short of export, purge, or billing-provider workflows.
|
||||||
|
- **Explicit non-goals**: No hard delete, no purge engine, no export-before-delete flow, no retention executor, no customer self-serve billing portal, no payment-provider integration, no membership auto-deletion, no new panel, no new global-search resource, no broad lifecycle engine, and no reopening of the full Spec 262 taxonomy package.
|
||||||
|
- **Permanent complexity imported**: One bounded closure/removal truth on existing records, one bounded write orchestration path, explicit close/reopen and remove/restore action surfaces, chooser and context recovery rules, and focused audit plus feature-test coverage. No new standalone closure table or workflow engine is introduced.
|
||||||
|
- **Why now**: Spec 262 deliberately reserved `Workspace & Tenant Closure Lifecycle v1` as the next runtime follow-through after the taxonomy-first package, and the repo now has enough real substrate in tenant operability, commercial lifecycle, audit, and workspace context handling to implement it safely.
|
||||||
|
- **Why not local**: Closure and removal semantics affect workspace selection, tenant selection, tenant-bound routes, canonical run viewers, action gating, audit, and platform/admin surfaces together. A local page fix would preserve ambiguity and drift.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: New lifecycle truth, cross-plane UI impact, and destructive-like action semantics. Defense: the slice stays explicitly bounded, reuses existing surfaces and audit paths, adds no purge or export behavior, and resists a generic lifecycle framework.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + tenant-bound + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/system/directory/workspaces`
|
||||||
|
- `/system/directory/workspaces/{workspace}`
|
||||||
|
- `/system/directory/tenants/{tenant}`
|
||||||
|
- `/system/ops/runs/{run}`
|
||||||
|
- `/admin/workspaces`
|
||||||
|
- `/admin/workspaces/{record}`
|
||||||
|
- `/admin/choose-workspace`
|
||||||
|
- `/admin/choose-tenant`
|
||||||
|
- `/admin/settings/workspace`
|
||||||
|
- existing tenant-management resource pages backed by `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||||
|
- existing tenant-context routes under `/admin/t/{tenant}/...`
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Workspace closure truth remains workspace-owned and lives on the existing `Workspace` record.
|
||||||
|
- Tenant removal truth remains workspace-owned on the existing `ManagedEnvironment` record and does not create cross-workspace sharing.
|
||||||
|
- Workspace memberships, tenant memberships, audit logs, evidence, review artifacts, and `OperationRun` history remain owned by their current records and are preserved in place.
|
||||||
|
- This feature does not introduce new historical ledgers, export bundles, or purge artifacts.
|
||||||
|
- **RBAC**:
|
||||||
|
- Authorization planes involved: platform `/system` for workspace close and reopen; admin `/admin` for workspace read-only visibility, chooser recovery, and tenant remove or restore within the current workspace.
|
||||||
|
- Non-members or actors outside the relevant workspace or tenant scope receive deny-as-not-found (`404`).
|
||||||
|
- Actors who are in scope but lack the required capability receive forbidden (`403`).
|
||||||
|
- Canonical record viewers continue to authorize off record ownership and entitlement, not off remembered workspace or tenant context.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: Closed workspaces and removed tenants must never become the remembered active context. Canonical viewers may still render historically linked records when entitlement allows, even if the current remembered context was cleared.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical viewers must validate workspace ownership of the record, referenced-tenant ownership where applicable, active actor entitlement to the workspace and referenced tenant, and route legitimacy independent of remembered selector state. Closed or removed lifecycle flags are product posture, not authorization shortcuts.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes
|
||||||
|
- **Interaction class(es)**: status messaging, badges, header actions, row or detail actions, chooser recovery messaging, canonical detail viewers, and audit-backed lifecycle copy
|
||||||
|
- **Systems touched**: `WorkspaceContext`, chooser pages, tenant operability, commercial lifecycle summaries, admin and system detail pages, audit logging, and canonical run or tenant viewers
|
||||||
|
- **Existing pattern(s) to extend**: `WorkspaceCommercialLifecycleResolver`, `TenantOperabilityService`, `BadgeCatalog` and `BadgeRenderer`, current audit-log infrastructure, current Filament action surfaces, and existing chooser or context-recovery flows
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: `WorkspaceContext`, `TenantOperabilityService`, `BadgeCatalog` and `BadgeRenderer`, `WorkspaceAuditLogger`, tenant audit logging, current Filament detail and table action patterns, and the current system-directory plus admin resource surfaces
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: The repo already has the shared chooser, audit, and status paths needed for bounded runtime delivery. What is missing is the explicit closure and removed-from-workspace truth those shared paths must consume.
|
||||||
|
- **Allowed deviation and why**: none. This feature must not create a parallel lifecycle language or a page-local closure vocabulary.
|
||||||
|
- **Consistency impact**: `Suspended read-only`, `Closed`, `Removed from workspace`, `Archived`, and `Provider missing` must remain distinct meanings with consistent badges, copy, and blocked-action explanations.
|
||||||
|
- **Review focus**: Reviewers must verify that workspace closure and tenant removal reuse the existing shared status, chooser, audit, and canonical viewer seams instead of introducing one-off page logic.
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes, by lifecycle gating and canonical historical viewing only
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: existing shared `OperationRun` start UX and current canonical monitoring surfaces remain authoritative
|
||||||
|
- **Delegated start/completion UX behaviors**: blocked starts in closed workspaces or removed-tenant contexts must happen before enqueue, create no new run, and keep current `View run` or canonical-detail links unchanged for already-existing history
|
||||||
|
- **Local surface-owned behavior that remains**: system and admin surfaces own only the close/reopen and remove/restore inputs plus the impact summary shown before confirmation
|
||||||
|
- **Queued DB-notification policy**: unchanged; this feature introduces no new queued notification family
|
||||||
|
- **Terminal notification path**: unchanged; no new `OperationRun` lifecycle is introduced
|
||||||
|
- **Exception required?**: none
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
N/A - no shared provider or platform boundary is broadened here. Workspace closure and tenant removal are workspace-owned lifecycle concerns and must not be modeled as provider state.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| System workspace detail close or reopen controls | yes | Native Filament | header actions, badges, audit-backed detail summaries | page, detail | no | n/a |
|
||||||
|
| Admin workspace detail read-only closure summary | yes | Native Filament | status messaging, summary sections | page, detail | no | n/a |
|
||||||
|
| Managed tenant list and detail remove or restore controls | yes | Native Filament | row/detail actions, badges, lifecycle explanations | page, detail | no | n/a |
|
||||||
|
| Workspace and tenant chooser recovery after closure or removal | yes | Native Filament + global context shell | navigation, shell recovery, selector messaging | shell, page | no | special shell handling only |
|
||||||
|
| Canonical run and tenant viewers for removed or closed history | yes | Native Filament | shared detail family, historical evidence visibility | detail | no | no new mutation surface |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace detail | Primary Decision Surface | Decide whether a workspace should be closed or reopened | Current posture, impact summary, closure reason, one dominant action | Audit history, affected tenant counts, related historical records | Primary because platform users make the closure decision here | Follows workspace governance, not storage internals | Removes guesswork about what closure changes and what remains readable |
|
||||||
|
| Managed tenant list and detail | Primary Decision Surface | Decide whether a tenant remains in the active workspace set | Tenant posture, removal impact, one dominant remove or restore action | Related memberships, historical runs, audit trail | Primary because workspace operators govern tenant presence here | Aligns to tenant-management workflow | Avoids overloading archive and implicit chooser disappearance |
|
||||||
|
| Admin workspace detail | Secondary Context Surface | Understand why the workspace is read-only or unavailable for active selection | Workspace posture, read-only explanation, next allowed inspection path | Closure reason, timestamps, linked history | Not primary because the admin plane does not own closure mutation | Supports post-decision inspection inside the workspace family | Keeps one clear explanation instead of repeating blockers across pages |
|
||||||
|
| Workspace and tenant chooser recovery | Secondary Context Surface | Recover from a cleared or invalid remembered context | Why the prior context is invalid and one next step | Optional timestamps or posture detail only if needed | Not primary because the user is recovering from an already-made decision | Follows operator recovery flow | Replaces silent redirects with one clear explanation |
|
||||||
|
| Canonical run and historical viewers | Tertiary Evidence / Diagnostics Surface | Inspect historical evidence after closure or removal | Historical record remains readable plus lifecycle badge on related workspace or tenant | Linked audit trail, tenant or workspace metadata | Not primary because the feature does not ask operators to decide here | Keeps evidence and history accessible without reopening active context | Prevents false not-found errors when context changed |
|
||||||
|
|
||||||
|
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace detail | support-platform | Current posture, closure reason summary, impact, close or reopen action | Related tenant counts, membership posture, recent activity | Full audit events and internal IDs | `Close workspace` or `Reopen workspace` | Raw audit payloads and low-level IDs remain secondary | One top summary states the posture once; lower sections add evidence only |
|
||||||
|
| Managed tenant list and detail | operator-MSP, support-platform | Tenant posture, removal explanation, remove or restore action | Related memberships, lifecycle timestamps, affected history counts | Raw audit details and internal identifiers | `Remove tenant` or `Restore tenant` | Raw audit details stay secondary or support-only | The tenant posture chip and summary line are the single visible source of truth |
|
||||||
|
| Admin workspace detail | operator-MSP | Workspace posture and what remains readable | Commercial-state detail, closure timestamps, guidance to history surfaces | Platform-only internal detail stays hidden | `Review workspace history` | Platform-only internal detail stays hidden | Read-only explanation appears once in the summary region |
|
||||||
|
| Workspace and tenant chooser recovery | operator-MSP | Why the context was cleared and which chooser or page to use next | Minimal recovery detail only | None | `Choose workspace` or `Choose tenant` | Internal route or entitlement diagnostics stay hidden | Recovery page states the blocker once and points to one next action |
|
||||||
|
| Canonical run and historical viewers | operator-MSP, support-platform | Historical record remains readable and the related workspace or tenant posture | Linked workspace and tenant metadata, timestamps, linked audit | Raw identifiers and platform-only debug metadata | `Review history` | Raw metadata remains secondary | The record summary owns the lifecycle note so deeper sections do not restate it |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace detail | Detail / Header Actions | Governance detail | Close or reopen workspace | Route-record detail | n/a | Secondary detail links stay inside sections | Detail header only with confirmation | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Workspace identity, posture badge | Workspace | Open, suspended read-only, or closed posture plus impact | none |
|
||||||
|
| Admin workspace detail | Detail / Context Summary | Workspace management detail | Review closure posture and history | Route-record detail | n/a | Related links inside sections | none on admin plane for closure | `/admin/workspaces` | `/admin/workspaces/{record}` | Workspace identity, posture badge | Workspace | Read-only explanation and next allowed path | none |
|
||||||
|
| Managed tenant list | List / Table / More | Registry list | Inspect tenant and decide remove or restore | Full-row click | required | `More` for remove or restore and rare actions | `More` only | `/admin/tenants` | `/admin/tenants/{tenant}` | Workspace context, tenant posture | Tenant | Active versus removed posture | none |
|
||||||
|
| Managed tenant detail | Detail / Header Actions | Governance detail | Remove or restore tenant | Route-record detail | n/a | Secondary lifecycle links inside sections | Detail header or `More` with confirmation | `/admin/tenants` | `/admin/tenants/{tenant}` | Workspace identity, tenant posture | Tenant | Removed-from-workspace state and impact | none |
|
||||||
|
| Workspace and tenant chooser recovery | Shell / Recovery | Global-context shell | Recover to a valid current context | Single recovery card / chooser list | chooser row click only | None beyond recovery guidance | none | `/admin/choose-workspace` or `/admin/choose-tenant` | same as collection | Current workspace and tenant context validity | Workspace / Tenant | Why the previous context is invalid | global-context-shell |
|
||||||
|
| Canonical run and historical viewers | Detail / Evidence | Historical detail | Inspect history without reopening active context | Route-record detail | n/a | Secondary related links inside sections | none | `/system/ops/runs` or current historical collection | `/system/ops/runs/{run}` and related detail routes | Workspace or tenant posture badges | Run / Historical record | Historical legitimacy plus related lifecycle note | shared-detail-family |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace detail | Platform operator | Decide whether workspace should be closed or reopened | Governance detail | Should this workspace remain operable? | Workspace posture, closure reason, impact, what remains readable | Full audit trail, internal IDs, related history counts | commercial posture, closure posture | TenantPilot only | Close workspace, Reopen workspace | Close workspace |
|
||||||
|
| Admin workspace detail | Workspace owner or manager | Understand why current workspace is read-only or unavailable for active context | Workspace management detail | What does this posture change for my workspace work? | Workspace posture, read-only explanation, history access path | Timestamps and related summary counts | commercial posture, closure posture | None on this surface | Review workspace history | None |
|
||||||
|
| Managed tenant list and detail | Workspace owner | Decide whether tenant remains part of the workspace's active operating set | Governance list/detail | Should this tenant stay operable in this workspace? | Tenant posture, removal explanation, effect on selectors and actions | Membership and historical record details | tenant lifecycle, removed-from-workspace posture | TenantPilot only | Remove tenant, Restore tenant | Remove tenant |
|
||||||
|
| Workspace and tenant chooser recovery | Workspace member | Recover from invalid remembered context | Global-context shell | Which valid workspace or tenant should I use now? | Recovery explanation and one next chooser action | Minimal recovery detail only | context validity, closure or removal posture | None | Choose workspace, Choose tenant | None |
|
||||||
|
| Canonical run and historical viewers | Workspace operator or platform operator | Inspect history after closure or removal | Historical detail | Is this record still legitimate and what lifecycle posture does it reflect? | Record summary plus related workspace or tenant posture | Audit links and related metadata | record status, workspace or tenant posture | None | Review history | None |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: yes - explicit workspace closed truth and explicit tenant removed-from-workspace truth on existing records
|
||||||
|
- **New persisted entity/table/artifact?**: yes - new persisted lifecycle fields on `Workspace` and `ManagedEnvironment`; no new standalone entity or table family beyond targeted columns
|
||||||
|
- **New abstraction?**: yes, one bounded write-orchestration service if needed for close/reopen and remove/restore audit-safe mutations; no generic lifecycle framework
|
||||||
|
- **New enum/state/reason family?**: yes - explicit closed and removed-from-workspace posture with audit reason text and derived blocked-action consequences
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: the product cannot currently make a deliberate closure or removal decision without misusing archive semantics, implicit 404s, or commercial suspension language.
|
||||||
|
- **Existing structure is insufficient because**: `archived_at` on workspaces and the subscription resolver's suspended-read-only posture do not encode an explicit closure decision, and current tenant lifecycle does not cover workspace-specific removal while preserving history.
|
||||||
|
- **Narrowest correct implementation**: add bounded fields on existing records, derive read-only and selector behavior from those fields plus current commercial posture, reuse current surfaces, and keep mutations inside one bounded orchestration seam.
|
||||||
|
- **Ownership cost**: migration work, model and middleware touch points, Filament surface updates, audit-log additions, and focused feature-test maintenance.
|
||||||
|
- **Alternative intentionally rejected**: overloading archive or suspended-read-only semantics was rejected because it would preserve ambiguity; a generic lifecycle engine or new closure table was rejected because current-release truth only needs bounded fields and shared-surface reuse.
|
||||||
|
- **Release truth**: current-release truth
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, migration shims, historical alias states, and compatibility-specific tests are out of scope unless implementation reveals a concrete current-release blocker.
|
||||||
|
|
||||||
|
Canonical replacement of ambiguous archive or suspension meanings is preferred over preserving overloaded semantics.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: The slice changes Filament surfaces, middleware or context recovery, audit-backed mutations, and canonical viewer legitimacy. Focused feature coverage is the narrowest honest proof because route, policy, and page behavior matter more than isolated unit indirection.
|
||||||
|
- **New or expanded test families**: focused feature coverage for system directory, admin workspace and tenant resources, chooser recovery, and canonical run or historical viewers
|
||||||
|
- **Fixture / helper cost impact**: moderate but bounded; test setup needs explicit workspace membership, tenant membership, commercial posture, and closure or removal state, but no new heavy provider or browser defaults
|
||||||
|
- **Heavy-family visibility / justification**: none; browser coverage is not required for the initial proof path
|
||||||
|
- **Special surface test profile**: standard-native-filament, global-context-shell, shared-detail-family
|
||||||
|
- **Standard-native relief or required special coverage**: functional-core plus state-contract coverage is required; browser or heavy-governance coverage is out of scope unless implementation proves a shell-only gap that feature tests cannot honestly prove
|
||||||
|
- **Reviewer handoff**: reviewers must verify that close/reopen and remove/restore remain confirmation-protected and audit-backed, that chooser recovery clears invalid context explicitly, that blocked start or mutate paths do not create runs, and that canonical historical viewers stay accessible when entitlement allows
|
||||||
|
- **Budget / baseline / trend impact**: none expected beyond one feature-local increase in admin and system feature coverage
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Directory/ViewWorkspaceClosureTest.php tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Close a workspace without losing history (Priority: P1)
|
||||||
|
|
||||||
|
As a platform operator, I need to close and later reopen a workspace explicitly, so that I can stop active operations and mutations while preserving readable history and audit evidence.
|
||||||
|
|
||||||
|
**Why this priority**: Workspace closure is the core enterprise-trust behavior missing from the current product. Without it, every related lifecycle action still depends on implicit archive or access loss.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by closing a workspace from the system directory, confirming chooser and action gating update, verifying historical viewers remain readable for entitled actors, and reopening the workspace to restore normal operability.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a platform actor is authorized to govern a workspace, **When** the actor closes the workspace with confirmation and a reason, **Then** the workspace becomes non-selectable as current context, in-scope active mutations are blocked, and the closure decision is written to audit.
|
||||||
|
2. **Given** a workspace is closed, **When** an entitled actor opens a historical run or workspace detail viewer, **Then** the record remains readable with explicit closed posture and no false not-found error.
|
||||||
|
3. **Given** a workspace is closed, **When** the platform actor reopens it, **Then** the workspace becomes selectable again and existing memberships plus historical records remain intact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Remove a tenant from a workspace without deleting tenant history (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace owner, I need to remove and later restore a tenant from the active workspace set, so that the tenant stops appearing as an operable context without destroying its history, memberships, or canonical records.
|
||||||
|
|
||||||
|
**Why this priority**: Workspace operators need a bounded lifecycle action smaller than deletion and more explicit than archive or hidden selector disappearance.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by removing a tenant from the tenant-management surface, confirming chooser and tenant-context routes stop treating it as operable, verifying canonical historical viewers still render, and restoring the tenant.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an owner is authorized for tenant governance, **When** the owner removes a tenant from the workspace with confirmation and a reason, **Then** the tenant no longer appears in choose-tenant or tenant-context start flows and the action is written to audit.
|
||||||
|
2. **Given** a tenant was removed from the workspace, **When** an entitled actor opens a canonical run or historical detail page that references that tenant, **Then** the record remains readable and the tenant is labeled removed from workspace.
|
||||||
|
3. **Given** a tenant is removed from the workspace, **When** the owner restores it, **Then** the tenant becomes selectable and operable again without recreating memberships or historical records.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Distinguish suspended read-only, closed, and removed clearly (Priority: P2)
|
||||||
|
|
||||||
|
As an operator, I need workspace and tenant posture to explain whether I am blocked because the workspace is commercially suspended, explicitly closed, or the tenant was removed from the workspace, so that I know what next action is legitimate.
|
||||||
|
|
||||||
|
**Why this priority**: Clear posture language prevents operators from treating commercial billing state, workspace closure, and tenant removal as the same event.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering admin and system surfaces plus chooser recovery for a suspended-read-only workspace, a closed workspace, and a removed tenant and confirming each posture has distinct copy, badge semantics, and blocked-action behavior.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a workspace is commercially suspended but not closed, **When** the actor opens admin or system summaries, **Then** the posture reads suspended read-only and not closed.
|
||||||
|
2. **Given** a workspace is closed, **When** the actor lands on chooser or current-context recovery surfaces, **Then** the UI explains that the prior workspace is closed and offers one valid next action instead of a silent redirect.
|
||||||
|
3. **Given** a tenant is removed from the workspace, **When** the actor visits tenant management or historical detail surfaces, **Then** the tenant is labeled removed from workspace and not archived or provider missing.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A user lands on `/admin` with a remembered current workspace that was closed after the last session; the product must clear the invalid context and route through an explicit recovery path.
|
||||||
|
- A remembered current tenant was removed from the workspace while the workspace itself remains open; the tenant context must be cleared without breaking workspace-scoped pages.
|
||||||
|
- A workspace is already `SUSPENDED_READ_ONLY` from the commercial resolver and then becomes explicitly closed; the product must show both truths without collapsing them into one ambiguous blocker.
|
||||||
|
- A removed tenant or closed workspace is still referenced by `OperationRun`, audit, evidence, or review artifacts; canonical viewers must stay readable when entitlement allows.
|
||||||
|
- A workspace close or tenant removal action is attempted while a last-owner or active-membership guard would otherwise make the result unsafe; the feature must preserve current owner-guard behavior and surface a clear failure reason.
|
||||||
|
- A reopen or restore action is performed after the remembered context was cleared; the product must not silently reactivate old context without the chooser or explicit selection flow confirming it.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no Microsoft Graph calls. It does introduce destructive-like lifecycle mutations, so close/reopen and remove/restore must be previewed, confirmation-protected, audit-logged, authorization-checked server-side, and covered by focused tests. No purge or hard-delete behavior is allowed in this slice.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature adds bounded persisted lifecycle truth on existing records because current release truth now requires a deliberate closure posture distinct from archive and commercial suspension. A generic lifecycle engine, registry, or new closure history ledger is explicitly out of scope. The narrowest correct implementation is to add fields on existing records, derive blocking behavior from those fields plus current commercial posture, and keep write orchestration bounded.
|
||||||
|
|
||||||
|
**Constitution alignment (XCUT-001):** The feature reuses existing chooser, context, badge, audit, and canonical-viewer seams. It must not introduce page-local status vocabularies or a parallel action language for closure and removal.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** System workspace detail and managed tenant detail are the primary decision surfaces. Chooser recovery and historical viewers remain secondary or tertiary surfaces. Default-visible content must explain posture and one next action; deeper audit and raw identifiers stay secondary.
|
||||||
|
|
||||||
|
**Constitution alignment (PROV-001):** This slice is platform-core lifecycle work. Provider presence remains a separate lifecycle dimension from Spec 261 and must not be reused to model closure or removal.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature coverage for native Filament surfaces, chooser recovery, and canonical viewers. No heavy-governance or browser lane should be introduced unless implementation reveals a shell-only gap that feature tests cannot honestly prove.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** The feature does not add a new `OperationRun` type. It does require that blocked starts in closed workspaces or removed-tenant contexts fail before enqueue and that existing canonical run viewers remain authoritative and readable when entitlement allows.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX-START-001):** Existing start surfaces continue to rely on the shared `OperationRun` start UX path when allowed. Close or remove posture only changes whether a start is permitted; it must not introduce local queued toast, dedupe, or notification semantics.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** Platform and admin planes remain separated. Workspace non-members, tenant non-members, and wrong-plane actors get `404`; in-scope actors lacking capability get `403`. Destructive-like actions must use `->requiresConfirmation()` and server-side authorization. Closed or removed posture is never a substitute for an authorization decision.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** This feature does not change auth-handshake behavior.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Workspace and tenant posture badges must be centralized. `Suspended read-only`, `Closed`, and `Removed from workspace` must not fall back to ad hoc labels or local color choices.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** All changed admin and system surfaces remain native Filament pages or resources. No local card system, no ad hoc status-color system, and no fake row interactivity may be introduced. One dominant action per primary decision surface is required.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Primary operator-facing verbs are `Close workspace`, `Reopen workspace`, `Remove tenant`, and `Restore tenant`. `Workspace` and `Tenant` are the real target objects and must remain stable across buttons, modals, audit prose, and notifications.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** This feature adds explicit human-in-the-loop lifecycle decisions. The default experience must become calmer and clearer by separating active context, read-only closure posture, and historical evidence rather than adding more ambiguous lifecycle states.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Changed surfaces must preserve exactly one primary inspect/open model, keep destructive actions in header or `More`, avoid redundant view actions, and keep chooser recovery and historical viewers focused on one immediate question.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** Workspace close/reopen and tenant remove/restore must not compete with navigation. Primary decision surfaces keep one dominant action; rare or adjacent lifecycle actions stay grouped.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Every changed surface must show whether the blocker is commercial suspension, explicit closure, or tenant removal. Mutation scope must stay legible: all lifecycle mutations in this slice are TenantPilot-only and do not mutate Microsoft tenants.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must map existing domain truth directly to UI posture without adding a new presentation taxonomy or semantic wrapper layer. Tests should prove user-facing business consequences and access behavior, not thin indirection.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** System workspace detail, admin workspace detail, managed tenant list and detail, chooser recovery, and any touched historical viewers must satisfy the action-surface contract described in the matrix below.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 - Layout & Information Architecture):** Existing Filament layouts remain in force. Any added summary or warning content must fit current Main/Aside or detail-section layouts, keep one primary action, and preserve native empty-state and badge semantics.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST model workspace closure as explicit product truth distinct from `archived_at` and distinct from commercial `SUSPENDED_READ_ONLY` posture.
|
||||||
|
- **FR-002**: Authorized platform actors MUST be able to close and reopen a workspace through an explicit confirmation flow that captures a human-readable reason and writes an audit event.
|
||||||
|
- **FR-003**: A closed workspace MUST stop being selectable as the current workspace in `/admin/choose-workspace` and MUST clear any remembered tenant context tied to that workspace.
|
||||||
|
- **FR-004**: A closed workspace MUST block the in-scope tenant start surface and the in-scope state-changing admin surfaces touched by this slice while preserving entitled read access to management and historical viewers.
|
||||||
|
- **FR-005**: The admin plane MUST show read-only closure posture for an affected workspace without exposing a second closure-mutation plane.
|
||||||
|
- **FR-006**: The system MUST model tenant removal from workspace as explicit product truth distinct from tenant archive and distinct from provider-missing lifecycle.
|
||||||
|
- **FR-007**: Authorized workspace actors MUST be able to remove and restore a tenant from the workspace through explicit confirmation flows that capture a reason and write audit events.
|
||||||
|
- **FR-008**: A removed tenant MUST stop being selectable in `/admin/choose-tenant` and MUST stop being a valid tenant-context route target under `/admin/t/{tenant}/...`.
|
||||||
|
- **FR-009**: A removed tenant MUST remain inspectable on tenant-management and canonical historical viewer surfaces when the actor remains entitled to the workspace and tenant history.
|
||||||
|
- **FR-010**: Canonical historical viewers in scope for this v1, specifically system run viewers and system workspace or tenant historical detail viewers, MUST remain viewable when they reference a closed workspace or removed tenant and entitlement still allows access.
|
||||||
|
- **FR-011**: The system MUST preserve existing workspace-membership and tenant-membership records during close/reopen and remove/restore flows in this v1 slice; no membership purge or recreation workflow is allowed.
|
||||||
|
- **FR-012**: The product MUST distinguish `Suspended read-only`, `Closed`, and `Removed from workspace` with separate badge semantics, copy, and blocked-action explanations.
|
||||||
|
- **FR-013**: Chooser and context-recovery surfaces MUST explain why a remembered context was cleared and MUST offer one valid next action rather than silently redirecting.
|
||||||
|
- **FR-014**: Authorization on affected surfaces MUST preserve `404` for non-members or wrong-plane actors and `403` for in-scope actors missing capability.
|
||||||
|
- **FR-015**: Lifecycle mutations in this feature MUST be labeled consistently as `Close workspace`, `Reopen workspace`, `Remove tenant`, and `Restore tenant` across buttons, modals, notifications, and audit prose.
|
||||||
|
- **FR-016**: Close/reopen and remove/restore actions MUST be TenantPilot-only mutations; no Microsoft tenant or provider mutation is performed in this slice.
|
||||||
|
- **FR-017**: Blocked starts caused by closed workspaces or removed tenants on the in-scope tenant start surface MUST not create `OperationRun` records, local blocked-run substitutes, or new notification families.
|
||||||
|
- **FR-018**: The feature MUST preserve current owner-guard and membership-safety behavior rather than bypassing it through lifecycle mutations.
|
||||||
|
- **FR-019**: This feature MUST not widen discovery: existing global-search and list entitlement rules remain unchanged, and no new discovery path may leak closed or removed records to actors who are not already entitled to inspect them.
|
||||||
|
- **FR-020**: The feature MUST not add purge, export-before-delete, retention execution, or billing-provider logic.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
If this feature adds or modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System workspace detail | `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` | `Close workspace` or `Reopen workspace` | Route-record detail | none | none | n/a | `Close workspace` or `Reopen workspace` | n/a | yes | Platform capability only; confirmation required; no second control plane on `/admin` |
|
||||||
|
| Admin workspace detail | `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php` | none added in this slice | Route-record detail | none | none | n/a | none for closure on admin plane | Save/cancel unchanged where edit already exists | no direct mutation | Read-only summary only |
|
||||||
|
| Managed tenant list | `apps/platform/app/Filament/Resources/TenantResource.php` list page | none beyond current list controls | Full-row click to tenant detail | current safe shortcuts only; `Remove tenant` or `Restore tenant` lives in `More` | none in this slice | existing tenant-creation CTA stays unchanged | n/a | n/a | yes for remove/restore | Destructive-like action grouped to `More` |
|
||||||
|
| Managed tenant detail | `apps/platform/app/Filament/Resources/TenantResource.php` view page | `Remove tenant` or `Restore tenant` plus existing safe navigation | Route-record detail | none | none | n/a | `Remove tenant` or `Restore tenant` | Save/cancel unchanged where edit already exists | yes | Owner or equivalent capability only; confirmation required |
|
||||||
|
| Workspace chooser | `apps/platform/app/Filament/Pages/ChooseWorkspace.php` | none | chooser row or card select | `Choose workspace` | none | existing empty-state CTA only if no valid workspace exists | n/a | n/a | no | Closed workspaces excluded from selectable set |
|
||||||
|
| Tenant chooser | `apps/platform/app/Filament/Pages/ChooseTenant.php` | none | chooser row or card select | `Choose tenant` for active tenants only | none | existing empty-state CTA only if no valid tenant exists | n/a | n/a | no | Removed tenants excluded from selectable set |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Workspace closure truth**: Explicit lifecycle posture on `Workspace` that marks a workspace as closed or reopened and records the reason and actor without deleting the workspace.
|
||||||
|
- **Tenant removal truth**: Explicit lifecycle posture on `ManagedEnvironment` that marks a tenant as removed from the active workspace set or restored without deleting the tenant record.
|
||||||
|
- **Remembered workspace and tenant context**: Convenience selection state that must be cleared when closure or removal makes it invalid.
|
||||||
|
- **Canonical historical viewer in scope**: Existing system run viewers plus system workspace and tenant detail viewers that remain readable even when active context becomes invalid.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: In validation scenarios, 100% of close/reopen and remove/restore actions show explicit posture and impact summary with no ambiguous fallback to `Archived`.
|
||||||
|
- **SC-002**: In validation scenarios, 0 closed workspaces and 0 removed tenants appear as selectable choices in the normal workspace and tenant choosers.
|
||||||
|
- **SC-003**: In validation scenarios, 100% of entitled system run viewers and in-scope system historical detail viewers remain accessible when they reference a closed workspace or removed tenant.
|
||||||
|
- **SC-004**: In validation scenarios, 100% of blocked actions explain whether the blocker is `Suspended read-only`, `Closed`, or `Removed from workspace`.
|
||||||
|
- **SC-005**: In validation scenarios, 100% of lifecycle mutations covered by this slice write audit events that include actor, prior state, new state, timestamp, and reason.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Workspace closure and tenant removal are the next runtime follow-through after the lifecycle taxonomy foundation from Spec 262. The feature does not attempt deletion, purge, or export. It introduces one explicit workspace-level closure truth, one explicit tenant removed-from-workspace truth, and the minimum surface, chooser, and canonical-view behavior needed to make those truths operable and auditable.
|
||||||
|
|
||||||
|
The key product distinction is that commercial suspension, explicit workspace closure, tenant removal from workspace, tenant archive, and provider-missing lifecycle are not the same thing. This feature productizes the first two missing runtime decisions without reopening the broader lifecycle package.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Let platform users close and reopen workspaces deliberately and auditably.
|
||||||
|
- Let workspace owners remove and restore tenants from the active workspace set without deleting history.
|
||||||
|
- Make chooser, context-recovery, and canonical-view behavior explicit when closure or removal invalidates active context.
|
||||||
|
- Keep suspended read-only posture distinct from explicit closure and tenant removal.
|
||||||
|
- Reuse current Filament, audit, chooser, and canonical-view seams rather than introducing a new lifecycle framework.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No purge, export-before-delete, or retention-execution workflow.
|
||||||
|
- No hard delete or membership-deletion cascade.
|
||||||
|
- No payment-provider or billing-portal work.
|
||||||
|
- No reopening of broader provider lifecycle or retention taxonomy work.
|
||||||
|
- No new panel, global-search resource, or custom UI system.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `WorkspaceCommercialLifecycleResolver` remains the source of commercial suspension truth and is not replaced by closure logic.
|
||||||
|
- Workspace memberships and tenant memberships remain the entitlement backbone even when a workspace is closed or a tenant is removed.
|
||||||
|
- Existing canonical historical viewers already have legitimate workspace identity and can keep rendering when lifecycle posture changes.
|
||||||
|
- Close/reopen and remove/restore are TenantPilot-only mutations in this v1.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Closure and removal could be mistakenly folded into existing archive semantics if the implementation does not keep badges, copy, and blocked-action explanations distinct.
|
||||||
|
- Chooser recovery could regress into silent redirects if the feature does not explicitly handle cleared context.
|
||||||
|
- Canonical historical viewers could accidentally inherit active-context requirements and start returning false not-found errors.
|
||||||
|
- The slice could widen into purge, export, or billing-provider logic unless the follow-up boundaries stay explicit.
|
||||||
|
|
||||||
|
## Follow-Up Work
|
||||||
|
|
||||||
|
- Export-before-delete and retention/purge governance remain separate follow-up specs.
|
||||||
|
- Any future customer self-service offboarding or billing-driven closure workflow remains a separate commercial or portal slice.
|
||||||
|
- Broader lifecycle alignment for other governed objects remains outside this workspace and tenant closure follow-through.
|
||||||
|
|
||||||
|
## Final Direction
|
||||||
|
|
||||||
|
The intended runtime contract is: a suspended workspace stays readable but commercially blocked, a closed workspace becomes non-selectable and read-only for entitled actors, and a removed tenant stops being an active workspace context without losing historical legitimacy. The implementation should add only the truth and behavior needed to make those distinctions real.
|
||||||
182
specs/292-workspace-tenant-closure/tasks.md
Normal file
182
specs/292-workspace-tenant-closure/tasks.md
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
---
|
||||||
|
description: "Task list for Workspace & Tenant Closure Lifecycle v1"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Workspace & Tenant Closure Lifecycle v1
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/292-workspace-tenant-closure/`
|
||||||
|
**Prerequisites**: `specs/292-workspace-tenant-closure/spec.md`, `specs/292-workspace-tenant-closure/plan.md`
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest). Keep proof bounded to focused `Feature` coverage for system directory, admin workspace and tenant surfaces, chooser recovery, and canonical historical viewers.
|
||||||
|
**Operations**: Reuse the existing `OperationRun` start UX and canonical run viewers. No new run type, no queue family, and no local blocked-run substitute are allowed.
|
||||||
|
**RBAC**: Wrong-plane or non-member access remains `404`; in-scope actors missing capability remain `403`. Closure/removal posture is never an authorization shortcut.
|
||||||
|
**Shared Pattern Reuse**: Reuse `WorkspaceContext`, `TenantOperabilityService`, `WorkspaceCommercialLifecycleResolver`, `BadgeCatalog` / `BadgeRenderer`, current audit infrastructure, and current Filament action-surface patterns.
|
||||||
|
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new globally searchable resource, and no new asset strategy are allowed.
|
||||||
|
**Organization**: Tasks are grouped by user story so workspace closure, tenant removal, and posture clarity remain independently implementable and testable.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment stays `fast-feedback` and `confidence` and remains the narrowest sufficient proof.
|
||||||
|
- [x] New or changed tests stay in focused `Feature` families only unless a bounded implementation seam proves a unit test is necessary.
|
||||||
|
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
|
||||||
|
- [x] Planned validation commands cover closure, removal, chooser recovery, and historical viewer legitimacy without widening into browser or heavy-governance lanes.
|
||||||
|
- [x] The declared surface test profiles remain `standard-native-filament`, `global-context-shell`, and `shared-detail-family` only.
|
||||||
|
- [x] Any drift toward purge, export, billing workflow, or a second mutation plane resolves as `reject-or-split`, not hidden scope.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Context)
|
||||||
|
|
||||||
|
**Purpose**: Confirm the current lifecycle, chooser, and history seams before any runtime change begins.
|
||||||
|
|
||||||
|
- [x] T001 Review `specs/292-workspace-tenant-closure/spec.md`, `specs/292-workspace-tenant-closure/plan.md`, `specs/262-lifecycle-governance-taxonomy/spec.md`, `specs/143-tenant-lifecycle-operability-context-semantics/spec.md`, and `specs/274-billing-subscription-truth/spec.md` together so the slice stays grounded in current lifecycle and commercial truth.
|
||||||
|
- [x] T002 [P] Confirm the current system and admin surface seams in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`, `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`, `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`, `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php`, and `apps/platform/app/Filament/Resources/TenantResource.php`.
|
||||||
|
- [x] T003 [P] Confirm the chooser and context seams in `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, and `apps/platform/app/Filament/Pages/ChooseTenant.php`.
|
||||||
|
- [x] T004 [P] Confirm the current lifecycle and audit seams in `apps/platform/app/Services/Tenants/TenantOperabilityService.php`, `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and the current tenant audit logging path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Add the bounded lifecycle truth and write-side seam before surface behavior changes.
|
||||||
|
|
||||||
|
**Critical**: No user-story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [x] T005 [P] Add failing feature coverage in `apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php` and `apps/platform/tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php` to lock close/reopen behavior, historical readability, and pre-enqueue blocking.
|
||||||
|
- [x] T006 [P] Add failing feature coverage in `apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php` and `apps/platform/tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php` to lock admin read-only posture, chooser recovery, and cleared-context behavior.
|
||||||
|
- [x] T007 [P] Add failing feature coverage in `apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php` to lock remove/restore behavior, chooser exclusion, and tenant-context denial rules.
|
||||||
|
- [x] T008 Create `apps/platform/database/migrations/*_add_workspace_closure_fields.php` and `apps/platform/database/migrations/*_add_managed_environment_workspace_removal_fields.php` so existing records can store explicit closure and removal truth.
|
||||||
|
- [x] T009 Update `apps/platform/app/Models/Workspace.php` and `apps/platform/app/Models/ManagedEnvironment.php` with casts, bounded helper methods, and relationship accessors for the new lifecycle truth.
|
||||||
|
- [x] T010 Implement `apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php` as the one bounded orchestration seam for close/reopen and remove/restore plus audit-safe state transitions, and keep membership rows preserved rather than recreated or deleted.
|
||||||
|
- [x] T011 Extend `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and the current tenant audit logging path with stable action IDs and metadata for close/reopen and remove/restore.
|
||||||
|
- [x] T012 Update the relevant policy and capability seams so close/reopen and remove/restore enforce server-side authorization with the required `404` versus `403` behavior.
|
||||||
|
|
||||||
|
**Checkpoint**: Workspace and tenant lifecycle truth exists, audit is wired, and write-side behavior is centralized before UI or chooser changes land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Close a workspace without losing history (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Authorized platform users can close and reopen workspaces explicitly while preserving readable history.
|
||||||
|
|
||||||
|
**Independent Test**: Close a workspace from the system detail page, confirm chooser and action gating update, verify historical viewers remain readable, and reopen the workspace.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php` to prove confirmation, impact summary, reason capture, clear guard-failure reasons on unsafe close attempts, canonical success or error notification copy, audit metadata, membership preservation, reopen behavior, and blocked mutation or start behavior.
|
||||||
|
- [x] T014 [P] [US1] Extend `apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php` to prove admin read-only posture, distinct closed versus suspended copy, and the absence of a second workspace-closure mutation plane.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T015 [US1] Update `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php` so the system surfaces render closure posture, impact summary, and keep closed-workspace history readable with canonical success or error notification copy and clear guard-failure messaging on unsafe attempts.
|
||||||
|
- [x] T016 [US1] Update `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, and `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` so closed workspaces clear invalid remembered context, route through explicit recovery messaging, and block the in-scope tenant start surface before enqueue.
|
||||||
|
- [x] T017 [US1] Update `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php` and `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` so the admin plane shows read-only closure posture, defers in-scope blocked-state checks to `apps/platform/app/Services/Workspaces/WorkspaceLifecycleService.php`, and does not expose closure mutation controls.
|
||||||
|
|
||||||
|
**Checkpoint**: Workspace closure becomes an explicit, auditable, read-only posture with preserved historical access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Remove a tenant from a workspace without deleting tenant history (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Workspace owners can remove and restore tenants from the active workspace set without losing historical legitimacy.
|
||||||
|
|
||||||
|
**Independent Test**: Remove a tenant from the tenant-management surface, confirm chooser and tenant-context routes treat it as non-operable, verify historical viewers still render, and restore the tenant.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T018 [P] [US2] Extend `apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php` to prove confirmation, impact summary, reason capture, clear guard-failure reasons on unsafe remove attempts, canonical success or error notification copy, audit metadata, membership preservation, restore behavior, chooser exclusion, tenant-context denial, and representative removed-tenant blocked-start no-`OperationRun` behavior on `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php`.
|
||||||
|
- [x] T019 [P] [US2] Extend `apps/platform/tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php` to prove historical viewers remain readable when the referenced tenant is removed from the workspace.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T020 [US2] Update `apps/platform/app/Filament/Resources/TenantResource.php` and the resource's view-page action surface so remove/restore actions, impact summaries, canonical success or error notification copy, posture badges, and grouped destructive actions follow the spec contract.
|
||||||
|
- [x] T021 [US2] Update `apps/platform/app/Services/Tenants/TenantOperabilityService.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, tenant-memory handling in `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, and `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` so removed tenants are no longer valid active context and cannot enqueue new runs.
|
||||||
|
- [x] T022 [US2] Update `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php` and `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php` so removed tenants remain historically visible with explicit posture and no false not-found behavior.
|
||||||
|
|
||||||
|
**Checkpoint**: Tenant removal becomes explicit, reversible, and historically safe without remaining an active workspace context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Distinguish suspended read-only, closed, and removed clearly (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Operators can tell which lifecycle posture is blocking them and what next action remains legitimate.
|
||||||
|
|
||||||
|
**Independent Test**: Render admin, system, chooser, and historical surfaces for suspended-read-only, closed, and removed states and verify distinct copy and badges.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T023 [P] [US3] Extend `apps/platform/tests/Feature/System/Directory/ViewWorkspaceClosureTest.php`, `apps/platform/tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php`, and `apps/platform/tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php` to prove distinct posture labels, centralized badge mapping, disclosure ordering, blocked-action explanations, and one dominant next action.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T024 [US3] Update posture rendering on `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`, `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php`, and `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` so `Suspended read-only` remains distinct from `Closed` and uses shared badge semantics rather than local mappings.
|
||||||
|
- [x] T025 [US3] Update posture rendering on `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`, and chooser recovery messaging so `Removed from workspace` remains distinct from archive and provider-missing semantics, and keep decision content first with diagnostics secondary.
|
||||||
|
|
||||||
|
**Checkpoint**: Lifecycle posture becomes explicit and non-ambiguous across the affected operator and history surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Validation
|
||||||
|
|
||||||
|
**Purpose**: Validate the bounded slice and stop without widening scope.
|
||||||
|
|
||||||
|
- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Directory/ViewWorkspaceClosureTest.php tests/Feature/System/Ops/ClosedWorkspaceHistoricalAccessTest.php`.
|
||||||
|
- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/Workspaces/WorkspaceClosureStatusTest.php tests/Feature/Filament/Resources/TenantResource/TenantWorkspaceRemovalTest.php tests/Feature/Filament/Pages/WorkspaceContextClosureRecoveryTest.php`.
|
||||||
|
- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- [x] T029 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no globally searchable resource or wider discovery path is added, no asset strategy changes appear, and no second closure-mutation plane slipped in.
|
||||||
|
- [x] T030 [P] Record the final guardrail and test-governance outcome in the implementation close-out without reopening purge, export, billing, or portal scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||||
|
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
|
||||||
|
- **Phase 3 (US1)**: depends on Phase 2 and establishes explicit workspace closure truth plus chooser recovery.
|
||||||
|
- **Phase 4 (US2)**: depends on Phase 2 and should land after or alongside US1 so tenant removal composes with the new workspace posture rules.
|
||||||
|
- **Phase 5 (US3)**: depends on Phases 3 and 4 because it clarifies the final shared posture language.
|
||||||
|
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: independently testable after Phase 2 and delivers the central workspace-closure capability.
|
||||||
|
- **US2 (P1)**: independently testable after Phase 2 and delivers the central tenant-removal capability.
|
||||||
|
- **US3 (P2)**: depends on the completed runtime posture rules from US1 and US2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the listed Pest coverage first and make it fail for the intended gap.
|
||||||
|
- Keep implementation inside the existing model, service, middleware, Filament, and audit seams named above.
|
||||||
|
- Re-run the narrowest relevant validation command after each story checkpoint before moving on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- MVP = **US1 + US2 together**. The feature is only trustworthy when workspace closure and tenant removal both exist and historical readability remains intact.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Phase 1 and Phase 2.
|
||||||
|
2. Deliver US1 so explicit workspace closure and chooser recovery exist.
|
||||||
|
3. Deliver US2 so tenants can be removed or restored without losing history.
|
||||||
|
4. Deliver US3 so posture language across the affected surfaces becomes unambiguous.
|
||||||
|
5. Finish with the focused validation and drift-review tasks in Phase 6.
|
||||||
|
|
||||||
|
### Team Strategy
|
||||||
|
|
||||||
|
1. Settle persistence and bounded service shape first.
|
||||||
|
2. Parallelize failing tests within each story before runtime edits.
|
||||||
|
3. Serialize merges around `WorkspaceContext`, `TenantOperabilityService`, `ViewWorkspace`, and `TenantResource` so posture language stays coherent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred Follow-Ups / Non-Goals
|
||||||
|
|
||||||
|
- export-before-delete workflow
|
||||||
|
- retention and purge governance
|
||||||
|
- customer self-serve workspace offboarding or billing-driven closure
|
||||||
|
- provider-level lifecycle expansion beyond the current separate specs
|
||||||
|
- lifecycle dashboard or workbench surfaces
|
||||||
Loading…
Reference in New Issue
Block a user