Compare commits

...

6 Commits

Author SHA1 Message Date
Ahmed Darrazi
e4db44f606 feat: complete workspace-first environment routing cutover
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m27s
2026-05-07 23:46:45 +02:00
Ahmed Darrazi
35156a72bc Merge commit '023274c46ce7cbe3cd1397bc80b31d383011d51b' into 280-workspace-tenancy-environment-routing 2026-05-07 23:43:09 +02:00
023274c46c feat: normalize provider connection scope contracts (#339)
## Summary
- normalize provider-neutral target-scope and identity contracts across provider connection resolution, operation-start gating, verification reporting, and boundary configuration
- align provider connection resource, onboarding, tenant summaries, and operation follow-up on the same shared scope contract while keeping Microsoft-specific profile details in provider-owned metadata
- add Spec 281 artifacts and focused feature/browser coverage for the new provider-scope contract
- move the tenant dashboard context-chip rail into Filament header widgets so the metadata row renders directly under the page subtitle

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Filament remains on v5 with Livewire v4-compatible surfaces only.
- Provider registration location is unchanged; Laravel 11+ providers stay in `apps/platform/bootstrap/providers.php`.
- `ProviderConnectionResource` remains non-globally-searchable and still exposes View/Edit pages.
- No new asset registration was added; deploy-time `filament:assets` expectations are unchanged.
- No new destructive action path was introduced; existing server authorization and confirmation handling remain in place where applicable.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #339
2026-05-07 19:28:42 +00:00
2952e5ad3e feat: polish tenant dashboard operations attention UX (#338)
## Summary
- rename the tenant dashboard operations KPI to attention-first wording and keep the primary header CTA derived from the highest-priority recommended action
- restyle the `Operations requiring attention` card to match the existing neutral dashboard card language while keeping only a subtle per-item attention accent
- replace technical operation identifiers on the dashboard with calmer timing/copy, including provider-consent follow-up messaging for blocked permission posture checks
- refresh the local Spec Kit artifacts for spec 273 so the branch documentation matches the implemented attention-only dashboard scope

## Validation
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #338
2026-05-07 16:55:17 +00:00
210508db9d 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
2026-05-07 13:12:17 +00:00
670c46dedd Spec 280: prepare workspace tenancy and environment routing cutover (#336)
## Summary
- add the implementation-ready spec-prep artifacts for Spec 280: Filament Workspace Tenancy & Environment Routing Cutover
- define the bounded scope, rollout constraints, route contract, and validation plan for the workspace-first routing cutover
- update the generated Copilot agent context for the active feature branch

## Testing
- not run; this branch adds spec-prep artifacts only and does not change application code

## Notes
- no application runtime, database, or frontend code changes are included in this PR
- target base branch requested: `platform-dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #336
2026-05-07 10:20:43 +00:00
173 changed files with 6900 additions and 613 deletions

View File

@ -4,6 +4,8 @@
namespace App\Console\Commands;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
@ -172,9 +174,9 @@ public function handle(): int
['User password', $password],
['ManagedEnvironment', (string) $tenant->name],
['ManagedEnvironment external id', (string) $tenant->external_id],
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
['Dashboard URL', TenantDashboard::getUrl(tenant: $tenant)],
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"],
['Blocked route', BackupSetResource::getUrl(panel: 'admin', tenant: $tenant)],
['Locally denied capability', 'tenant.view'],
],
);

View File

@ -65,10 +65,6 @@ private static function currentPanelId(mixed $request): ?string
: null;
if (is_string($routeName) && $routeName !== '') {
if (str_contains($routeName, '.tenant.')) {
return 'tenant';
}
if (str_contains($routeName, '.admin.')) {
return 'admin';
}
@ -78,10 +74,6 @@ private static function currentPanelId(mixed $request): ?string
? '/'.ltrim((string) $request->path(), '/')
: null;
if (is_string($path) && str_starts_with($path, '/admin/t/')) {
return 'tenant';
}
if (is_string($path) && str_starts_with($path, '/admin/')) {
return 'admin';
}

View File

@ -447,7 +447,6 @@ public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?st
return BaselineCompareLanding::getUrl(
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
panel: 'tenant',
tenant: $tenant,
);
}

View File

@ -126,7 +126,7 @@ public function selectTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
public function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation

View File

@ -63,8 +63,10 @@ public function getWorkspaces(): Collection
->where('user_id', $user->getKey());
})
->whereNull('archived_at')
->whereNull('closed_at')
->withCount(['tenants' => function ($query): void {
$query->where('lifecycle_status', 'active');
$query->where('lifecycle_status', 'active')
->whereNull('removed_from_workspace_at');
}])
->orderBy('name')
->get();
@ -94,7 +96,7 @@ public function selectWorkspace(int $workspaceId): void
abort(404);
}
if (! empty($workspace->archived_at)) {
if (! $workspace->isSelectableAsContext()) {
abort(404);
}

View File

@ -565,7 +565,7 @@ private function findingDetailUrl(Finding $record): string
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}

View File

@ -694,7 +694,7 @@ private function findingDetailUrl(Finding $record): string
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}

View File

@ -280,7 +280,7 @@ public function emptyState(): array
'action_name' => 'open_tenant_findings_empty',
'action_label' => 'Open tenant findings',
'action_kind' => 'url',
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant),
'action_url' => FindingResource::getUrl('index', tenant: $activeTenant),
];
}
@ -636,7 +636,7 @@ private function findingDetailUrl(Finding $record): string
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}

View File

@ -681,7 +681,7 @@ public function decisionUrl(FindingException $record): ?string
}
return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant),
FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $tenant),
$this->navigationContext()->toQuery(),
);
}

View File

@ -523,7 +523,7 @@ public function basisRunSummary(): array
'badgeColor' => null,
'runUrl' => null,
'historyUrl' => null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}
@ -539,7 +539,7 @@ public function basisRunSummary(): array
'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}

View File

@ -443,7 +443,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
],
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'tenant')
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
];
}

View File

@ -243,7 +243,7 @@ protected function getHeaderActions(): array
return null;
}
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
return FindingExceptionResource::getUrl('index', tenant: $tenant);
});
$selectedContextActions = [
@ -490,7 +490,7 @@ public function selectedExceptionUrl(): ?string
}
return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
}
@ -504,7 +504,7 @@ public function selectedFindingUrl(): ?string
}
return $this->appendQuery(
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
}

View File

@ -204,7 +204,7 @@ protected function getHeaderActions(): array
->label('Back to '.$activeTenant->name)
->icon('heroicon-o-arrow-left')
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant));
}
if ($activeTenant instanceof ManagedEnvironment) {
@ -218,7 +218,7 @@ protected function getHeaderActions(): array
$this->removeTableFilter('managed_environment_id');
$this->redirect('/admin/operations');
$this->redirect(OperationRunLinks::index(allTenants: true));
});
}
@ -432,6 +432,7 @@ private function shouldForceWorkspaceWideTenantScope(): bool
private function operationsUrl(array $overrides = []): string
{
$parameters = array_merge(
['workspace' => app(WorkspaceContext::class)->currentWorkspace(request())],
$this->navigationContext()?->toQuery() ?? [],
[
'tenant_scope' => $this->shouldForceWorkspaceWideTenantScope() ? 'all' : null,

View File

@ -126,7 +126,7 @@ protected function getHeaderActions(): array
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name)
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant));
} else {
$actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations')

View File

@ -255,6 +255,21 @@ public function content(Schema $schema): Schema
->content(fn (): string => $this->commercialPostureReasonText())
->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')
->description('Review current support-access posture and decide pending workspace recovery requests.')
->columns(2)
@ -1740,6 +1755,7 @@ private function currentUserCanManage(): bool
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $this->workspace)
&& ! $this->workspace->isClosed()
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
}
@ -1755,6 +1771,7 @@ private function currentUserCanApproveSupportAccess(): bool
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $this->workspace)
&& ! $this->workspace->isClosed()
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)
&& $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)) {
abort(403);
}
if ($this->workspace->isClosed()) {
throw ValidationException::withMessages([
'workspace' => 'This workspace is closed. Reopen it before changing workspace settings.',
]);
}
}
}

View File

@ -11,6 +11,7 @@
use App\Models\SupportRequest;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
@ -90,7 +91,42 @@ public function getSubheading(): string | Htmlable | null
*/
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
$resolvedTenant = $tenant instanceof ManagedEnvironment
? $tenant
: (($parameters['tenant'] ?? $parameters['environment'] ?? null) instanceof ManagedEnvironment
? ($parameters['tenant'] ?? $parameters['environment'])
: null);
if (! $resolvedTenant instanceof ManagedEnvironment) {
return url('/admin');
}
$workspace = $parameters['workspace'] ?? null;
if (! $workspace instanceof Workspace) {
$workspace = $resolvedTenant->workspace()->first();
}
if (! $workspace instanceof Workspace) {
return url('/admin');
}
return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$resolvedTenant->getRouteKey());
}
/**
* @return array<class-string<Widget> | WidgetConfiguration>
*/
protected function getHeaderWidgets(): array
{
return [
TenantDashboardContextChips::class,
];
}
public function getHeaderWidgetsColumns(): int|array
{
return 1;
}
/**
@ -100,7 +136,6 @@ public function getWidgets(): array
{
return [
TenantTriageArrivalContinuity::class,
TenantDashboardContextChips::class,
DashboardKpis::class,
TenantDashboardOverview::class,
];

View File

@ -38,7 +38,7 @@ class TenantRequiredPermissions extends Page implements HasTable
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
protected static ?string $slug = 'workspaces/{workspace}/environments/{tenant}/required-permissions';
protected static ?string $title = 'Required permissions';

View File

@ -1473,16 +1473,16 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr
'verification_state' => $this->stringValue($connection->verification_status),
'readiness_summary' => 'Target scope needs review',
'target_scope_summary' => 'Target scope needs review',
'provider_context' => [
'provider' => (string) $connection->provider,
'details' => [],
],
'contextual_identity_line' => null,
'is_enabled' => (bool) $connection->is_enabled,
];
}
return array_merge($summary->toArray(), [
'target_scope_summary' => $summary->targetScopeSummary(),
'contextual_identity_line' => $summary->contextualIdentityLine(),
'is_enabled' => (bool) $connection->is_enabled,
]);
return $summary->toArray();
}
/**
@ -2658,7 +2658,10 @@ private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $
'shared_label' => 'Target scope',
'shared_help_text' => 'The platform scope this provider connection represents.',
],
'provider_identity_context' => [],
'provider_context' => [
'provider' => (string) $connection->provider,
'details' => [],
],
], $extra);
}
}
@ -5139,7 +5142,7 @@ public function completeOnboarding(): void
resourceId: (string) $tenant->getKey(),
);
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
private function verificationRun(): ?OperationRun

View File

@ -81,7 +81,7 @@ public function getTenants(): Collection
public function goToChooseTenant(): void
{
$this->redirect(ChooseTenant::getUrl());
$this->redirect(route('admin.workspace.managed-tenants.index', ['workspace' => $this->workspace]));
}
public function openTenant(int $tenantId): void
@ -106,6 +106,8 @@ public function openTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
$this->redirect(
\App\Filament\Pages\TenantDashboard::getUrl(tenant: $tenant)
);
}
}

View File

@ -270,7 +270,7 @@ public static function relatedContextEntries(FindingException $record): array
label: 'Finding',
value: static::findingSummary($record),
secondaryValue: 'Return to the linked finding detail.',
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant),
targetKind: 'direct_record',
priority: 10,
actionLabel: 'Open finding',

View File

@ -2082,7 +2082,7 @@ private static function findingExceptionViewUrl(\App\Models\FindingException $ex
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
}
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
return FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant);
}
/**

View File

@ -1618,7 +1618,7 @@ public static function restoreContinuation(OperationRun $record): ?array
'follow_up_required' => $attention->followUpRequired,
'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label,
'link_url' => $canOpenRestore
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'tenant', tenant: $tenant)
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant)
: null,
'link_available' => $canOpenRestore,
];
@ -1657,6 +1657,21 @@ private static function targetScopeDisplay(OperationRun $record): ?string
return null;
}
$scopeDisplayName = $targetScope['scope_display_name'] ?? null;
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
$scopeDisplayName = is_string($scopeDisplayName) ? trim($scopeDisplayName) : null;
$scopeIdentifier = is_string($scopeIdentifier) ? trim($scopeIdentifier) : null;
if ($scopeDisplayName !== null && $scopeDisplayName !== '') {
return $scopeIdentifier !== null && $scopeIdentifier !== '' && $scopeIdentifier !== $scopeDisplayName
? "{$scopeDisplayName} ({$scopeIdentifier})"
: $scopeDisplayName;
}
if ($scopeIdentifier !== null && $scopeIdentifier !== '') {
return $scopeIdentifier;
}
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
$directoryContextId = $targetScope['directory_context_id'] ?? null;

View File

@ -506,7 +506,7 @@ private static function targetScopeSummary(?ProviderConnection $record): string
}
}
private static function providerIdentityContext(?ProviderConnection $record): ?string
private static function providerContextSummary(?ProviderConnection $record): ?string
{
if (! $record instanceof ProviderConnection) {
return null;
@ -539,7 +539,10 @@ public static function targetScopeAuditMetadata(ProviderConnection $record, arra
'shared_label' => 'Target scope',
'shared_help_text' => static::targetScopeHelpText(),
],
'provider_identity_context' => [],
'provider_context' => [
'provider' => (string) $record->provider,
'details' => [],
],
], $extra);
}
}
@ -681,9 +684,9 @@ public static function infolist(Schema $schema): Schema
->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Infolists\Components\TextEntry::make('provider_identity_context')
->label('Provider identity details')
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
Infolists\Components\TextEntry::make('provider_context')
->label('Provider context')
->state(fn (ProviderConnection $record): ?string => static::providerContextSummary($record))
->placeholder('n/a')
->columnSpanFull(),
Infolists\Components\TextEntry::make('last_error_reason_code')

View File

@ -568,7 +568,7 @@ public static function currentReportUrlFor(StoredReport $report): ?string
return null;
}
return static::getUrl('view', ['record' => $current], panel: 'tenant', tenant: $tenant);
return static::getUrl('view', ['record' => $current], tenant: $tenant);
}
public static function scopeCurrentRecords(Builder $query): Builder
@ -663,6 +663,6 @@ private static function tenantOverviewUrl(): string
return '#';
}
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
return TenantDashboard::getUrl(tenant: $tenant);
}
}

View File

@ -33,6 +33,7 @@
use App\Services\Tenants\TenantActionPolicySurface;
use App\Services\Tenants\TenantOperabilityService;
use App\Services\Verification\StartVerification;
use App\Services\Workspaces\WorkspaceLifecycleService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips;
@ -61,6 +62,7 @@
use App\Support\Tenants\TenantOperabilityOutcome;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -147,6 +149,8 @@ public static function canEdit(Model $record): bool
$resolver = app(CapabilityResolver::class);
return $record instanceof ManagedEnvironment
&& ! $record->isRemovedFromWorkspace()
&& ! $record->workspace?->isClosed()
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
}
@ -162,6 +166,8 @@ public static function canDelete(Model $record): bool
$resolver = app(CapabilityResolver::class);
return $record instanceof ManagedEnvironment
&& ! $record->isRemovedFromWorkspace()
&& ! $record->workspace?->isClosed()
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
}
@ -195,7 +201,9 @@ public static function makeAdminConsentAction(): Actions\Action
->label('Grant admin consent')
->icon('heroicon-o-clipboard-document')
->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(),
)
->preserveVisibility()
@ -400,9 +408,115 @@ public static function makeArchiveTenantAction(TenantActionSurface $surface, ?st
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
{
if (! $record->isActive()) {
if (! static::tenantSetupMutationVisible($record)) {
return false;
}
@ -635,6 +749,7 @@ public static function getEloquentQuery(): Builder
return parent::getEloquentQuery()
->withTrashed()
->whereIn('id', $tenantIds)
->with('workspace')
->withCount('policies')
->withMax('policies as last_policy_sync_at', 'last_synced_at');
}
@ -748,6 +863,17 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
->description(fn (ManagedEnvironment $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
->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')
->dateTime()
->since()
@ -869,6 +995,7 @@ public static function table(Table $table): Table
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->visible(fn (ManagedEnvironment $record): bool => static::canEdit($record))
->url(fn (ManagedEnvironment $record) => static::getUrl('edit', ['record' => $record]))
)
->requireCapability(Capabilities::TENANT_MANAGE)
@ -942,6 +1069,7 @@ public static function table(Table $table): Table
);
}),
static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
static::makeRestoreTenantToWorkspaceAction(),
static::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
@ -999,6 +1127,7 @@ public static function table(Table $table): Table
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
static::makeRemoveTenantFromWorkspaceAction(),
static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow),
])
->label('More')
@ -1215,14 +1344,13 @@ public static function tenantDashboardOpenUrl(ManagedEnvironment $record, array
$arrivalState = static::portfolioArrivalStateForTenant($record, $triageState);
if ($arrivalState === null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $record);
return TenantDashboard::getUrl(tenant: $record);
}
return TenantDashboard::getUrl(
parameters: [
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState),
],
panel: 'tenant',
tenant: $record,
);
}
@ -2352,9 +2480,19 @@ public static function infolist(Schema $schema): Schema
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(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')
->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(),
])
->columns(2)
@ -2762,7 +2900,9 @@ public static function tenantEditContextHtml(?ManagedEnvironment $tenant): HtmlS
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
@ -2772,7 +2912,24 @@ public static function tenantViewExternalGroupVisible(ManagedEnvironment $tenant
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
@ -2812,6 +2969,7 @@ private static function tenantActionCatalogCacheKey(ManagedEnvironment $tenant,
$surface->value,
(string) ($tenant->getKey() ?? 'missing'),
(string) $tenant->status,
(string) ($tenant->removed_from_workspace_at?->getTimestamp() ?? 'not-removed-from-workspace'),
(string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'),
(string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'),
(string) ($relatedDraft?->getKey() ?? 'no-draft'),
@ -3059,7 +3217,7 @@ public static function rbacAction(): Actions\Action
->noSearchResultsMessage('No security groups found')
->loadingMessage('Searching groups...'),
])
->visible(fn (ManagedEnvironment $record): bool => $record->isActive())
->visible(fn (ManagedEnvironment $record): bool => static::tenantSetupMutationVisible($record))
->disabled(function (ManagedEnvironment $record): bool {
$user = auth()->user();
@ -3203,7 +3361,8 @@ public static function adminConsentUrl(ManagedEnvironment $tenant): ?string
* consent_status:?string,
* verification_status:?string,
* last_health_check_at:?string,
* last_error_reason_code:?string
* last_error_reason_code:?string,
* target_scope_summary:?string
* }
*/
private static function providerConnectionState(ManagedEnvironment $tenant): array
@ -3238,9 +3397,16 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
'verification_status' => null,
'last_health_check_at' => null,
'last_error_reason_code' => null,
'target_scope_summary' => null,
];
}
try {
$targetScopeSummary = ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
} catch (\InvalidArgumentException) {
$targetScopeSummary = 'Target scope needs review';
}
return [
'state' => $connection->is_default ? 'default_configured' : 'configured',
'cta_url' => $ctaUrl,
@ -3257,6 +3423,7 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
: (is_string($connection->verification_status) ? $connection->verification_status : null),
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
'target_scope_summary' => $targetScopeSummary,
];
}

View File

@ -74,7 +74,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (ManagedEnvironment $record): bool => $record->isActive())
->visible(fn (ManagedEnvironment $record): bool => TenantResource::tenantSetupMutationVisible($record))
->action(function (ManagedEnvironment $record): void {
$user = auth()->user();
@ -152,6 +152,8 @@ protected function getHeaderActions(): array
&& TenantResource::tenantViewTriageGroupVisible($this->getRecord())),
Actions\ActionGroup::make([
TenantResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader),
TenantResource::makeRestoreTenantToWorkspaceAction(),
TenantResource::makeRemoveTenantFromWorkspaceAction(),
TenantResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader),
])
->label('Lifecycle')

View File

@ -48,6 +48,7 @@
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Panel;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
@ -87,6 +88,15 @@ public static function shouldRegisterNavigation(): bool
return Filament::getCurrentPanel()?->getId() === 'tenant';
}
public static function getSlug(?Panel $panel = null): string
{
if ($panel?->getId() === 'admin') {
return 'tenant-reviews';
}
return parent::getSlug($panel);
}
public static function getNavigationGroup(): string
{
return __('localization.review.reporting');
@ -605,7 +615,7 @@ public static function tenantScopedUrl(
?ManagedEnvironment $tenant = null,
?string $panel = null,
): string {
$panelId = $panel ?? 'tenant';
$panelId = 'admin';
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
}

View File

@ -17,9 +17,11 @@ protected function getHeaderActions(): array
{
return [
WorkspaceUiEnforcement::forTableAction(
Actions\EditAction::make(),
Actions\EditAction::make()
->visible(fn (): bool => WorkspaceResource::canEdit($this->record)),
fn (): ?Workspace => $this->record,
)
->preserveVisibility()
->requireCapability(Capabilities::WORKSPACE_MANAGE)
->apply(),
];

View File

@ -6,7 +6,10 @@
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Workspaces\WorkspaceLifecycleService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -18,6 +21,7 @@
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Forms;
use Filament\Infolists;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
@ -91,6 +95,7 @@ public static function canEdit(Model $record): bool
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $record)
&& ! $record->isClosed()
&& $resolver->can($user, $record, Capabilities::WORKSPACE_MANAGE);
}
@ -161,13 +166,26 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('slug')
->searchable()
->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([
ActionGroup::make([
WorkspaceUiEnforcement::forTableAction(
Actions\EditAction::make(),
Actions\EditAction::make()
->visible(fn (Workspace $record): bool => static::canEdit($record)),
fn (): ?Workspace => null,
)
->preserveVisibility()
->requireCapability(Capabilities::WORKSPACE_MANAGE)
->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
{
return [

View File

@ -6,6 +6,7 @@
use App\Models\PlatformUser;
use App\Models\ManagedEnvironment;
use App\Services\Workspaces\WorkspaceLifecycleService;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -94,6 +95,14 @@ public function table(Table $table): Table
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(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')
->label('Health')
->state(fn (ManagedEnvironment $record): string => $this->healthForTenant($record))
@ -110,6 +119,10 @@ public function table(Table $table): Table
private function healthForTenant(ManagedEnvironment $tenant): string
{
if ($tenant->isRemovedFromWorkspace()) {
return 'unknown';
}
if ((string) $tenant->status === ManagedEnvironment::STATUS_ARCHIVED) {
return 'unknown';
}

View File

@ -17,6 +17,7 @@
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
use App\Services\Settings\SettingsWriter;
use App\Services\Workspaces\WorkspaceLifecycleService;
use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog;
@ -70,7 +71,7 @@ public function workspaceTenants(): Collection
->where('workspace_id', (int) $this->workspace->getKey())
->orderBy('name')
->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
{
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')
->label('Request support access')
->icon('heroicon-o-lifebuoy')
@ -397,6 +464,14 @@ private function canManageCommercialLifecycle(): bool
&& $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
{
$user = auth('platform')->user();

View File

@ -8,6 +8,7 @@
use App\Models\PlatformUser;
use App\Models\ManagedEnvironment;
use App\Models\Workspace;
use App\Services\Workspaces\WorkspaceLifecycleService;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -81,6 +82,14 @@ public function table(Table $table): Table
->searchable(),
TextColumn::make('tenants_count')
->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')
->label('Health')
->state(fn (Workspace $record): string => $this->healthForWorkspace($record))

View File

@ -53,7 +53,7 @@ protected function getViewData(): array
return $empty;
}
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
$tenantLandingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
$operationsFollowUpCount = (int) OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->dashboardNeedsFollowUp()
@ -179,7 +179,7 @@ private function findingsUrl(ManagedEnvironment $tenant, TenantGovernanceAggrega
default => [],
};
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
return FindingResource::getUrl('index', $parameters, tenant: $tenant);
}
private function canOpenFindings(ManagedEnvironment $tenant): bool

View File

@ -164,7 +164,7 @@ protected function getViewData(): array
'badge' => 'Baseline',
'badgeColor' => $compareAssessment->tone,
'actionLabel' => 'Open Baseline Compare',
'actionUrl' => BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
'actionUrl' => BaselineCompareLanding::getUrl(tenant: $tenant),
];
}
@ -248,7 +248,7 @@ protected function getViewData(): array
private function findingsAction(ManagedEnvironment $tenant, string $label, array $parameters): array
{
$url = $this->canOpenFindings($tenant)
? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant)
? FindingResource::getUrl('index', $parameters, tenant: $tenant)
: null;
return [
@ -435,7 +435,7 @@ private function backupHealthActionPayload(ManagedEnvironment $tenant, ?BackupHe
'actionLabel' => $label ?? $target->label,
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
],
@ -443,7 +443,7 @@ private function backupHealthActionPayload(ManagedEnvironment $tenant, ?BackupHe
'actionLabel' => $label ?? $target->label,
'actionUrl' => BackupScheduleResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
],
@ -467,7 +467,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant,
'actionLabel' => 'Open backup sets',
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => 'The latest backup detail is no longer available.',
];
@ -481,7 +481,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant,
'actionUrl' => BackupSetResource::getUrl('view', [
'record' => $target->recordId,
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
];
@ -490,7 +490,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant,
'actionLabel' => 'Open backup sets',
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => 'The latest backup detail is no longer available.',
];
@ -564,7 +564,7 @@ private function recoveryActionPayload(ManagedEnvironment $tenant, array $recove
'actionUrl' => RestoreRunResource::getUrl('view', [
'record' => (int) $latestRun->getKey(),
'recovery_posture_reason' => $reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
];
@ -591,6 +591,6 @@ private function restoreRunListUrl(ManagedEnvironment $tenant, string $reason):
{
return RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $reason,
], panel: 'tenant', tenant: $tenant);
], tenant: $tenant);
}
}

View File

@ -123,13 +123,13 @@ private function resolveBackupHealthAction(ManagedEnvironment $tenant, ?BackupHe
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => null,
],
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
'actionUrl' => BackupScheduleResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => null,
],
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->resolveBackupSetAction($tenant, $target),
@ -146,7 +146,7 @@ private function resolveBackupSetAction(ManagedEnvironment $tenant, BackupHealth
return [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => 'The latest backup detail is no longer available.',
];
}
@ -158,14 +158,14 @@ private function resolveBackupSetAction(ManagedEnvironment $tenant, BackupHealth
'actionUrl' => BackupSetResource::getUrl('view', [
'record' => $target->recordId,
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => null,
];
} catch (ModelNotFoundException) {
return [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => 'The latest backup detail is no longer available.',
];
}
@ -200,7 +200,7 @@ private function resolveRecoveryAction(ManagedEnvironment $tenant, array $recove
'actionUrl' => RestoreRunResource::getUrl('view', [
'record' => (int) $latestRun->getKey(),
'recovery_posture_reason' => $reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => null,
];
} catch (ModelNotFoundException) {
@ -233,6 +233,6 @@ private function restoreRunListUrl(ManagedEnvironment $tenant, string $reason):
{
return RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $reason,
], panel: 'tenant', tenant: $tenant);
], tenant: $tenant);
}
}

View File

@ -43,6 +43,7 @@ protected function getViewData(): array
'recommendedActions' => [],
'governanceStatus' => [],
'readinessCards' => [],
'activeOperationSummary' => null,
'recentOperations' => [],
'pollingInterval' => null,
];

View File

@ -159,7 +159,7 @@ protected function getViewData(): array
'canManage' => $canManage,
'canView' => $canView,
'viewReportUrl' => $canView
? StoredReportResource::getUrl('view', ['record' => $report], panel: 'tenant', tenant: $tenant)
? StoredReportResource::getUrl('view', ['record' => $report], tenant: $tenant)
: null,
];
}

View File

@ -36,10 +36,10 @@ protected function getViewData(): array
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
: null;
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
$nextActionUrl = match ($aggregate->nextActionTarget) {
'run' => $runUrl,
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', tenant: $tenant),
'landing' => $landingUrl,
default => null,
};

View File

@ -4,6 +4,7 @@
namespace App\Http\Controllers;
use App\Support\OperationRunLinks;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -26,7 +27,7 @@ public function __invoke(Request $request): RedirectResponse
$previousPath = (string) (parse_url((string) $previousUrl, PHP_URL_PATH) ?? '');
if ($previousHost !== null && $previousHost !== $request->getHost()) {
return redirect()->route('admin.operations.index');
return redirect()->to(OperationRunLinks::index());
}
if ($this->isTenantScopedEvidencePath($previousPath)) {
@ -44,7 +45,7 @@ public function __invoke(Request $request): RedirectResponse
}
if ($previousPath === '' || $previousPath === '/admin/clear-tenant-context') {
return redirect()->route('admin.operations.index');
return redirect()->to(OperationRunLinks::index());
}
return redirect()->to((string) $previousUrl);

View File

@ -67,7 +67,7 @@ public function __invoke(Request $request): RedirectResponse
abort(404);
}
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, ManagedEnvironment $tenant): void

View File

@ -50,11 +50,6 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
// ManagedEnvironment-scoped routes are handled separately.
if (str_starts_with($path, '/admin/t/')) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
@ -77,7 +72,7 @@ public function handle(Request $request, Closure $next): Response
if (
$workspace instanceof Workspace
&& empty($workspace->archived_at)
&& $workspace->isSelectableAsContext()
&& $context->isMember($user, $workspace)
) {
return $next($request);
@ -85,7 +80,7 @@ public function handle(Request $request, Closure $next): Response
$this->clearStaleSession($context, $user, $request, $workspace);
if ($workspace instanceof Workspace && empty($workspace->archived_at)) {
if ($workspace instanceof Workspace && $workspace->isSelectableAsContext()) {
abort(404);
}
@ -97,6 +92,7 @@ public function handle(Request $request, Closure $next): Response
->where('user_id', $user->getKey())
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->whereNull('workspaces.closed_at')
->select('workspace_memberships.*')
->get();
@ -135,7 +131,7 @@ public function handle(Request $request, Closure $next): Response
if (
$lastWorkspace instanceof Workspace
&& empty($lastWorkspace->archived_at)
&& $lastWorkspace->isSelectableAsContext()
&& $context->isMember($user, $lastWorkspace)
) {
$context->setCurrentWorkspace($lastWorkspace, $user, $request);
@ -160,8 +156,12 @@ public function handle(Request $request, Closure $next): Response
$user->forceFill(['last_workspace_id' => null])->save();
if ($workspaceName !== null) {
$message = $lastWorkspace?->isClosed()
? "The workspace {$workspaceName} was closed."
: "Your access to {$workspaceName} was removed.";
Notification::make()
->title("Your access to {$workspaceName} was removed.")
->title($message)
->danger()
->send();
}
@ -189,16 +189,12 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
return true;
}
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $refererPath) === 1) {
return true;
}
}
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
return false;
}
private function isLivewireUpdatePath(string $path): bool
@ -266,8 +262,12 @@ private function clearStaleSession(WorkspaceContext $context, User $user, Reques
}
if ($workspaceName !== null) {
$message = $workspace?->isClosed()
? "The workspace {$workspaceName} was closed."
: "Your access to {$workspaceName} was removed.";
Notification::make()
->title("Your access to {$workspaceName} was removed.")
->title($message)
->danger()
->send();
}

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -136,16 +137,19 @@ private function resolveEntraTenantName(ProviderConnection $connection, Provider
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
$targetScope['scope_display_name'] = $entraTenantName;
}
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
$context['target_scope'] = $targetScope;
$context['provider_context'] = $normalizer->providerContext(
provider: (string) $connection->provider,
details: $normalizer->contextualIdentityDetailsForConnection($connection),
);
$run->update(['context' => $context]);
}

View File

@ -23,6 +23,7 @@
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Verification\TenantPermissionCheckClusters;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable;
@ -178,6 +179,9 @@ public function handle(
}
$permissionChecks = TenantPermissionCheckClusters::buildChecks($tenant, $permissionRows, $inventory);
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)
->descriptorForConnection($connection)
->toArray();
$report = VerificationReportWriter::write(
run: $this->operationRun,
@ -196,8 +200,8 @@ public function handle(
'value' => (int) $connection->getKey(),
],
[
'kind' => 'entra_tenant_id',
'value' => (string) $connection->entra_tenant_id,
'kind' => 'target_scope_identifier',
'value' => (string) ($targetScope['scope_identifier'] ?? $connection->entra_tenant_id),
],
is_numeric($result->meta['http_status'] ?? null) ? [
'kind' => 'http_status',
@ -224,7 +228,7 @@ public function handle(
],
identity: [
'provider_connection_id' => (int) $connection->getKey(),
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'target_scope' => $targetScope,
],
);
@ -360,17 +364,20 @@ private function resolveEntraTenantName(ProviderConnection $connection, HealthRe
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
$targetScope['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
$targetScope['scope_display_name'] = $entraTenantName;
}
$context['target_scope'] = $targetScope;
$context['provider_context'] = $normalizer->providerContext(
provider: (string) $connection->provider,
details: $normalizer->contextualIdentityDetailsForConnection($connection),
);
$run->update(['context' => $context]);
}
@ -453,9 +460,9 @@ private function logVerificationResult(
'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId,
'target_scope' => $identity->targetScope?->toArray(),
'provider_identity_context' => array_map(
'provider_context' => array_map(
static fn ($detail): array => $detail->toArray(),
$identity->contextualIdentityDetails,
$identity->providerContextDetails,
),
'reason_code' => $reasonCode,
'operation_run_id' => (int) $run->getKey(),

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -136,16 +137,19 @@ private function resolveEntraTenantName(ProviderConnection $connection, Provider
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
$targetScope['scope_display_name'] = $entraTenantName;
}
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
$context['target_scope'] = $targetScope;
$context['provider_context'] = $normalizer->providerContext(
provider: (string) $connection->provider,
details: $normalizer->contextualIdentityDetailsForConnection($connection),
);
$run->update(['context' => $context]);
}

View File

@ -38,6 +38,7 @@ class ManagedEnvironment extends Model implements HasName
'metadata' => 'array',
'is_current' => 'boolean',
'rbac_last_checked_at' => 'datetime',
'removed_from_workspace_at' => 'datetime',
];
public function getExternalIdAttribute(): ?string
@ -241,7 +242,11 @@ public static function activeQuery(): Builder
{
return static::query()
->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
@ -336,6 +341,11 @@ public function workspace(): BelongsTo
return $this->belongsTo(Workspace::class);
}
public function removedFromWorkspaceByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'removed_from_workspace_by_user_id');
}
public function roleMappings(): HasMany
{
return $this->hasMany(TenantRoleMapping::class);
@ -552,11 +562,35 @@ public function isArchived(): 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
{
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;
}
}

View File

@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
@ -15,6 +16,25 @@ class Workspace extends Model
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>
*/
@ -81,4 +101,26 @@ public function tenantSettings(): HasMany
{
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;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\CrossTenantComparePage;
@ -28,6 +29,7 @@
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\User;
use App\Models\Workspace;
@ -36,6 +38,7 @@
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\Filament\PanelThemeAsset;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
@ -139,7 +142,7 @@ public function panel(Panel $panel): Panel
->exists();
}),
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
->url(fn (): string => route('admin.operations.index'))
->url(fn (): string => OperationRunLinks::index())
->icon('heroicon-o-queue-list')
->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10),
@ -176,10 +179,12 @@ public function panel(Panel $panel): Panel
WorkspaceResource::class,
BaselineProfileResource::class,
BaselineSnapshotResource::class,
TenantReviewResource::class,
])
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->pages([
BaselineCompareLanding::class,
InventoryCoverage::class,
TenantRequiredPermissions::class,
WorkspaceSettings::class,

View File

@ -55,6 +55,10 @@ final class WorkspaceCommercialLifecycleResolver
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(
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
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' => $subscriptionSummary['key_date'] ?? null,
'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');
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);
}
@ -201,6 +219,14 @@ public function reviewPackStartDecisionForTenant(ManagedEnvironment $tenant): ar
*/
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(
$workspace,
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
{
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(
$workspace,
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
{
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) {
return $this->decision(
lifecycle: $lifecycle,
@ -330,6 +375,22 @@ private function lifecycleMessage(array $lifecycle, string $message): string
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
*/
@ -368,6 +429,9 @@ private function decision(
'source' => (string) $lifecycle['source'],
'source_label' => (string) $lifecycle['source_label'],
'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,
];
}

View File

@ -72,7 +72,7 @@ public static function unresolvedFoundation(string $label, string $foundationTyp
public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, ManagedEnvironment $tenant): self
{
$maskedId = static::mask($targetId);
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], panel: 'tenant', tenant: $tenant) : null;
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], tenant: $tenant) : null;
return new self(
targetLabel: $label,

View File

@ -66,6 +66,19 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
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
{
return $this->forceFailNonTerminalRun(
@ -124,6 +137,8 @@ public function ensureRun(
throw new InvalidArgumentException('ManagedEnvironment must belong to a workspace to start an operation run.');
}
$this->assertTenantAllowsNewOperation($tenant);
$hash = $this->calculateHash($tenant->id, $type, $inputs);
// 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.');
}
$this->assertTenantAllowsNewOperation($tenant);
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
// 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.');
}
$this->assertTenantAllowsNewOperation($tenant);
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
$existing = OperationRun::query()

View File

@ -25,7 +25,7 @@ public function make(ProviderConnection $connection, string $state): string
throw new RuntimeException($resolution->message ?? 'Provider identity could not be resolved for admin consent.');
}
$tenantSegment = trim($resolution->tenantContext) !== '' ? trim($resolution->tenantContext) : 'organizations';
$tenantSegment = $resolution->targetScopeIdentifier('organizations') ?? 'organizations';
return "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
'client_id' => $resolution->effectiveClientId,

View File

@ -44,10 +44,14 @@ public function getClientCredentials(ProviderConnection $connection): array
throw new RuntimeException('Provider credential payload is missing required keys.');
}
$tenantId = $payload['managed_environment_id'] ?? null;
$targetScopeIdentifier = $payload['managed_environment_id'] ?? null;
if (is_string($tenantId) && $tenantId !== '' && $tenantId !== $connection->entra_tenant_id) {
throw new InvalidArgumentException('Provider credential managed_environment_id does not match the connection entra_tenant_id.');
if (
is_string($targetScopeIdentifier)
&& $targetScopeIdentifier !== ''
&& $targetScopeIdentifier !== $connection->entra_tenant_id
) {
throw new InvalidArgumentException('Provider credential target scope does not match the connection target scope.');
}
return [

View File

@ -10,70 +10,70 @@
final class PlatformProviderIdentityResolver
{
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
public function resolve(
string $tenantContext,
string $targetScopeIdentifier,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
array $providerContextDetails = [],
): ProviderIdentityResolution {
$targetTenant = trim($tenantContext);
$targetScopeIdentifier = trim($targetScopeIdentifier);
$clientId = trim((string) config('graph.client_id'));
$clientSecret = trim((string) config('graph.client_secret'));
$authorityTenant = trim((string) config('graph.managed_environment_id', 'organizations'));
$redirectUri = trim((string) route('admin.consent.callback'));
if ($targetTenant === '') {
if ($targetScopeIdentifier === '') {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Platform,
tenantContext: 'organizations',
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
message: 'Provider connection is missing target tenant scope.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
$targetScope ??= ProviderConnectionTargetScopeDescriptor::fromInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $targetScopeIdentifier,
);
if ($clientId === '') {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Platform,
tenantContext: $targetTenant,
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
message: 'Platform app identity is not configured.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if ($clientSecret === '' || $redirectUri === '') {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Platform,
tenantContext: $targetTenant,
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
message: 'Platform app identity is incomplete.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
return ProviderIdentityResolution::resolved(
connectionType: ProviderConnectionType::Platform,
tenantContext: $targetTenant,
targetScope: $targetScope,
effectiveClientId: $clientId,
credentialSource: 'platform_config',
clientSecret: $clientSecret,
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
redirectUri: $redirectUri,
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails !== []
? array_values(array_merge($contextualIdentityDetails, array_filter([
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
])))
: [],
providerContextDetails: array_values(array_merge($providerContextDetails, array_filter([
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
]))),
);
}
}

View File

@ -62,7 +62,7 @@ public function graphOptions(ProviderConnection $connection, array $overrides =
}
return array_merge([
'tenant' => $resolution->tenantContext,
'tenant' => $resolution->targetScopeIdentifier('organizations'),
'client_id' => $resolution->effectiveClientId,
'client_secret' => $resolution->clientSecret,
'client_request_id' => (string) Str::uuid(),

View File

@ -10,12 +10,12 @@
final class ProviderIdentityResolution
{
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
private function __construct(
public readonly bool $resolved,
public readonly ProviderConnectionType $connectionType,
public readonly string $tenantContext,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly ?string $effectiveClientId,
public readonly string $credentialSource,
public readonly ?string $clientSecret,
@ -23,25 +23,26 @@ private function __construct(
public readonly ?string $redirectUri,
public readonly ?string $reasonCode,
public readonly ?string $message,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly array $contextualIdentityDetails,
public readonly array $providerContextDetails,
) {}
/**
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
public static function resolved(
ProviderConnectionType $connectionType,
string $tenantContext,
ProviderConnectionTargetScopeDescriptor $targetScope,
string $effectiveClientId,
string $credentialSource,
?string $clientSecret,
?string $authorityTenant,
?string $redirectUri,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
array $providerContextDetails = [],
): self {
return new self(
resolved: true,
connectionType: $connectionType,
tenantContext: $tenantContext,
targetScope: $targetScope,
effectiveClientId: $effectiveClientId,
credentialSource: $credentialSource,
clientSecret: $clientSecret,
@ -49,26 +50,25 @@ public static function resolved(
redirectUri: $redirectUri,
reasonCode: null,
message: null,
targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext),
contextualIdentityDetails: $contextualIdentityDetails !== []
? $contextualIdentityDetails
: self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri),
providerContextDetails: $providerContextDetails,
);
}
/**
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
public static function blocked(
ProviderConnectionType $connectionType,
string $tenantContext,
string $credentialSource,
string $reasonCode,
?string $message = null,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
array $providerContextDetails = [],
): self {
return new self(
resolved: false,
connectionType: $connectionType,
tenantContext: $tenantContext,
targetScope: $targetScope,
effectiveClientId: null,
credentialSource: $credentialSource,
clientSecret: null,
@ -76,10 +76,7 @@ public static function blocked(
redirectUri: null,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
message: $message,
targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null),
contextualIdentityDetails: $contextualIdentityDetails !== []
? $contextualIdentityDetails
: self::contextualIdentityDetails($tenantContext),
providerContextDetails: $providerContextDetails,
);
}
@ -88,35 +85,51 @@ public function effectiveReasonCode(): string
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
}
private static function targetScopeFromContext(string $tenantContext): ProviderConnectionTargetScopeDescriptor
public function targetScopeIdentifier(?string $fallback = null): ?string
{
$identifier = trim($tenantContext) !== '' ? trim($tenantContext) : 'organizations';
$identifier = trim((string) $this->targetScope?->scopeIdentifier);
return ProviderConnectionTargetScopeDescriptor::fromInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $identifier,
scopeDisplayName: $identifier,
);
if ($identifier !== '') {
return $identifier;
}
return $fallback;
}
/**
* @return list<ProviderIdentityContextMetadata>
* @return array{client_id: ?string, credential_source: string}
*/
private static function contextualIdentityDetails(
string $tenantContext,
?string $authorityTenant = null,
?string $redirectUri = null,
): array {
$details = [
ProviderIdentityContextMetadata::microsoftTenantId($tenantContext),
ProviderIdentityContextMetadata::authorityTenant($authorityTenant),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
public function effectiveClientIdentity(): array
{
return [
'client_id' => $this->effectiveClientId,
'credential_source' => $this->credentialSource,
];
}
return array_values(array_filter(
$details,
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
));
/**
* @return array{provider: string, details: list<array<string, string>>}
*/
public function providerContext(): array
{
$provider = $this->targetScope?->provider;
if (! is_string($provider) || trim($provider) === '') {
foreach ($this->providerContextDetails as $detail) {
if (trim($detail->provider) !== '') {
$provider = $detail->provider;
break;
}
}
}
return [
'provider' => is_string($provider) && trim($provider) !== '' ? trim($provider) : 'unknown',
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$this->providerContextDetails,
),
];
}
}

View File

@ -22,61 +22,58 @@ public function __construct(
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
{
$tenantContext = trim((string) $connection->entra_tenant_id);
$targetScopeIdentifier = trim((string) $connection->entra_tenant_id);
$connectionType = $this->resolveConnectionType($connection);
$targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection);
$targetScope = $targetScopeResult['target_scope'] ?? null;
$contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
$providerContextDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
if ($connectionType === null) {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Platform,
tenantContext: $tenantContext !== '' ? $tenantContext : 'organizations',
credentialSource: 'unknown',
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
message: 'Provider connection type is invalid.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
return ProviderIdentityResolution::blocked(
connectionType: $connectionType,
tenantContext: 'organizations',
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if ((bool) $connection->migration_review_required) {
return ProviderIdentityResolution::blocked(
connectionType: $connectionType,
tenantContext: $tenantContext,
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
message: 'Provider connection requires migration review before use.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if ($connectionType === ProviderConnectionType::Platform) {
return $this->platformResolver->resolve(
tenantContext: $tenantContext,
targetScopeIdentifier: $targetScopeIdentifier,
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
return $this->resolveDedicatedIdentity(
connection: $connection,
tenantContext: $tenantContext,
targetScopeIdentifier: $targetScopeIdentifier,
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
@ -97,36 +94,42 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide
private function resolveDedicatedIdentity(
ProviderConnection $connection,
string $tenantContext,
string $targetScopeIdentifier,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
array $providerContextDetails = [],
): ProviderIdentityResolution {
try {
$credentials = $this->credentials->getClientCredentials($connection);
} catch (InvalidArgumentException|RuntimeException $exception) {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Dedicated,
tenantContext: $tenantContext,
credentialSource: $this->credentialSource($connection),
reasonCode: $exception instanceof InvalidArgumentException
? ProviderReasonCodes::DedicatedCredentialInvalid
: ProviderReasonCodes::DedicatedCredentialMissing,
message: $exception->getMessage(),
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if (! $targetScope instanceof ProviderConnectionTargetScopeDescriptor) {
$targetScope = ProviderConnectionTargetScopeDescriptor::fromInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $targetScopeIdentifier !== '' ? $targetScopeIdentifier : 'organizations',
);
}
return ProviderIdentityResolution::resolved(
connectionType: ProviderConnectionType::Dedicated,
tenantContext: $tenantContext,
targetScope: $targetScope,
effectiveClientId: $credentials['client_id'],
credentialSource: $this->credentialSource($connection),
clientSecret: $credentials['client_secret'],
authorityTenant: $tenantContext,
authorityTenant: $targetScope->scopeIdentifier,
redirectUri: trim((string) route('admin.consent.callback')),
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}

View File

@ -12,6 +12,9 @@
use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
use App\Support\Verification\BlockedVerificationReportFactory;
use App\Support\Verification\StaleQueuedVerificationReportFactory;
use App\Support\Verification\VerificationReportWriter;
@ -28,6 +31,7 @@ public function __construct(
private readonly ProviderConnectionResolver $resolver,
private readonly ProviderNextStepsRegistry $nextStepsRegistry,
private readonly OperationRunCapabilityResolver $capabilityResolver,
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
) {}
/**
@ -137,9 +141,9 @@ public function start(
'module' => $definition['module'],
'provider_binding' => $this->bindingContext($binding),
'provider_connection_id' => (int) $lockedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
],
'connection_type' => $lockedConnection->connection_type?->value ?? $lockedConnection->connection_type,
'target_scope' => $this->targetScopeContextForConnection($lockedConnection),
'provider_context' => $this->providerContextForConnection($lockedConnection),
]);
$run = $this->runs->ensureRunWithIdentity(
@ -185,9 +189,9 @@ private function startBlocked(
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
'provider' => $provider,
'module' => $module,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
'target_scope' => $connection instanceof ProviderConnection
? $this->targetScopeContextForConnection($connection)
: $this->targetScopeContextForTenant($tenant, $provider),
]);
$identityInputs = [
@ -202,6 +206,8 @@ private function startBlocked(
if ($connection instanceof ProviderConnection) {
$context['provider_connection_id'] = (int) $connection->getKey();
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
$context['provider_context'] = $this->providerContextForConnection($connection);
$identityInputs['provider_connection_id'] = (int) $connection->getKey();
}
@ -287,6 +293,71 @@ private function bindingContext(array $binding): array
];
}
/**
* @return array{
* provider: string,
* scope_kind: string,
* scope_identifier: string,
* scope_display_name: string,
* shared_label: string,
* shared_help_text: string
* }
*/
private function targetScopeContextForConnection(ProviderConnection $connection): array
{
try {
return $this->targetScopeNormalizer->descriptorForConnection($connection)->toArray();
} catch (InvalidArgumentException) {
$identifier = trim((string) $connection->entra_tenant_id);
$fallbackIdentifier = $connection->tenant instanceof ManagedEnvironment
? trim((string) $connection->tenant->graphTenantId())
: '';
return ProviderConnectionTargetScopeDescriptor::fromInput(
provider: (string) $connection->provider,
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $identifier !== '' ? $identifier : ($fallbackIdentifier !== '' ? $fallbackIdentifier : (string) $connection->getKey()),
scopeDisplayName: (string) ($connection->tenant?->name ?? $connection->display_name ?? $identifier),
)->toArray();
}
}
/**
* @return array{
* provider: string,
* scope_kind: string,
* scope_identifier: string,
* scope_display_name: string,
* shared_label: string,
* shared_help_text: string
* }
*/
private function targetScopeContextForTenant(ManagedEnvironment $tenant, string $provider): array
{
$identifier = trim($tenant->providerTenantContext());
return ProviderConnectionTargetScopeDescriptor::fromInput(
provider: $provider !== '' ? $provider : 'unknown',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $identifier,
scopeDisplayName: (string) ($tenant->name ?? $identifier),
)->toArray();
}
/**
* @return array{provider: string, details: list<array<string, string>>}
*/
private function providerContextForConnection(ProviderConnection $connection): array
{
return [
'provider' => (string) $connection->provider,
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection),
),
];
}
/**
* @param array<string, mixed> $extraContext
*/

View File

@ -263,10 +263,7 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s
public function reviewPackGenerationDecisionForTenant(ManagedEnvironment $tenant): array
{
$tenant->loadMissing('workspace');
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
$tenant->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START,
);
$decision = $this->workspaceCommercialLifecycleResolver->reviewPackStartDecisionForTenant($tenant);
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
? $decision['entitlement_decision']

View File

@ -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) {
TenantOperabilityQuestion::SelectorEligibility => $this->selectorEligibilityOutcome($context, $lifecycle),
TenantOperabilityQuestion::RememberedContextValidity => $this->rememberedContextOutcome($context, $lifecycle),
@ -240,7 +294,11 @@ public function applySelectableScope(Builder $query, ?string $table = null): Bui
return $query
->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

View File

@ -120,9 +120,9 @@ public function providerConnectionCheckUsingConnection(
'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId,
'target_scope' => $identity->targetScope?->toArray(),
'provider_identity_context' => array_map(
'provider_context' => array_map(
static fn ($detail): array => $detail->toArray(),
$identity->contextualIdentityDetails,
$identity->providerContextDetails,
),
],
]),

View File

@ -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;
}
}

View File

@ -15,6 +15,8 @@ enum AuditActionId: string
case TenantArchived = 'tenant.archived';
case TenantRestored = 'tenant.restored';
case TenantReturnedToDraft = 'tenant.returned_to_draft';
case TenantRemovedFromWorkspace = 'tenant.removed_from_workspace';
case TenantRestoredToWorkspace = 'tenant.restored_to_workspace';
case TenantMembershipAdd = 'tenant_membership.add';
case TenantMembershipRoleChange = 'tenant_membership.role_change';
@ -62,6 +64,8 @@ enum AuditActionId: string
case WorkspaceSettingUpdated = 'workspace_setting.updated';
case WorkspaceSettingReset = 'workspace_setting.reset';
case WorkspaceSubscriptionUpdated = 'workspace_subscription.updated';
case WorkspaceClosed = 'workspace.closed';
case WorkspaceReopened = 'workspace.reopened';
case BaselineProfileCreated = 'baseline_profile.created';
case BaselineProfileUpdated = 'baseline_profile.updated';
@ -193,6 +197,8 @@ private static function labels(): array
self::TenantArchived->value => 'ManagedEnvironment archived',
self::TenantRestored->value => 'ManagedEnvironment restored',
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::TenantMembershipRoleChange->value => 'ManagedEnvironment member role change',
self::TenantMembershipRemove->value => 'ManagedEnvironment member removal',
@ -227,6 +233,8 @@ private static function labels(): array
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
self::WorkspaceSettingReset->value => 'Workspace setting reset',
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
self::WorkspaceClosed->value => 'Workspace closed',
self::WorkspaceReopened->value => 'Workspace reopened',
self::BaselineProfileCreated->value => 'Baseline profile created',
self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived',
@ -333,6 +341,8 @@ private static function summaries(): array
self::TenantArchived->value => 'ManagedEnvironment archived',
self::TenantRestored->value => 'ManagedEnvironment restored',
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::TenantMembershipRoleChange->value => 'ManagedEnvironment member role changed',
self::TenantMembershipRemove->value => 'ManagedEnvironment member removed',
@ -342,6 +352,8 @@ private static function summaries(): array
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
self::WorkspaceSettingReset->value => 'Workspace setting reset',
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
self::WorkspaceClosed->value => 'Workspace closed',
self::WorkspaceReopened->value => 'Workspace reopened',
self::BaselineProfileCreated->value => 'Baseline profile created',
self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived',

View File

@ -18,6 +18,8 @@ class PlatformCapabilities
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 SUPPORT_ACCESS_MANAGE = 'platform.support_access.manage';

View File

@ -40,6 +40,7 @@ final class BadgeCatalog
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
BadgeDomain::TenantWorkspacePosture->value => Domains\TenantWorkspacePostureBadge::class,
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
@ -60,6 +61,7 @@ final class BadgeCatalog
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
BadgeDomain::WorkspaceClosurePosture->value => Domains\WorkspaceClosurePostureBadge::class,
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,

View File

@ -31,6 +31,7 @@ enum BadgeDomain: string
case BooleanEnabled = 'boolean_enabled';
case BooleanHasErrors = 'boolean_has_errors';
case TenantStatus = 'tenant_status';
case TenantWorkspacePosture = 'tenant_workspace_posture';
case TenantRbacStatus = 'tenant_rbac_status';
case TenantPermissionStatus = 'tenant_permission_status';
case PolicySnapshotMode = 'policy_snapshot_mode';
@ -51,6 +52,7 @@ enum BadgeDomain: string
case BaselineProfileStatus = 'baseline_profile_status';
case FindingType = 'finding_type';
case ReviewPackStatus = 'review_pack_status';
case WorkspaceClosurePosture = 'workspace_closure_posture';
case CommercialLifecycleState = 'commercial_lifecycle_state';
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
case EvidenceCompleteness = 'evidence_completeness';

View File

@ -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(),
};
}
}

View File

@ -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(),
};
}
}

View File

@ -766,7 +766,7 @@ private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNav
+ ($finding->reopened_at !== null ? 0 : 1),
'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(),
'destination_url' => $this->appendQuery(
FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant),
FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',

View File

@ -4,6 +4,7 @@
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\Workspace;
use App\Services\Providers\AdminConsentUrlFactory;
final class RequiredPermissionsLinks
@ -15,7 +16,17 @@ final class RequiredPermissionsLinks
*/
public static function requiredPermissions(ManagedEnvironment $tenant, array $filters = []): string
{
$base = sprintf('/admin/tenants/%s/required-permissions', urlencode((string) $tenant->external_id));
$workspace = $tenant->workspace()->first();
if (! $workspace instanceof Workspace) {
return url('/admin');
}
$base = url(sprintf(
'/admin/workspaces/%s/environments/%s/required-permissions',
urlencode((string) ($workspace->slug ?? $workspace->getKey())),
urlencode((string) $tenant->getRouteKey()),
));
if ($filters === []) {
return $base;

View File

@ -32,6 +32,14 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
if ($tenant->isRemovedFromWorkspace()) {
abort(404);
}
if ($tenant->workspace?->isClosed()) {
abort(404);
}
return $next($request);
}
}

View File

@ -11,6 +11,7 @@
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
@ -45,6 +46,12 @@ public function handle(Request $request, Closure $next): Response
$user = $request->user();
if ($existingTenant instanceof ManagedEnvironment && $user instanceof User && ! $user->canAccessTenant($existingTenant)) {
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)) {
@ -70,19 +77,13 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
if ($path === '/admin/operations') {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if ($path === '/admin/operations/'.$request->route('run')) {
if (preg_match('#^/admin/workspaces/[^/]+/operations(?:/[^/]+)?$#', $path) === 1) {
$this->configureNavigationForRequest($panel);
return $next($request);
@ -96,6 +97,12 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
$workspace = $workspaceContext->currentWorkspace($request);
if ($workspace !== null) {
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]);
}
return redirect()->route('filament.admin.pages.choose-tenant');
}
@ -103,11 +110,19 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->isRemovedFromWorkspace() && str_starts_with($path, '/admin/workspaces/')) {
abort(404);
}
if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->workspace?->isClosed() && str_starts_with($path, '/admin/workspaces/')) {
abort(404);
}
if (
$resolvedContext->hasTenant()
&& (
$panel?->getId() === 'tenant'
|| (! $this->isWorkspaceScopedPageWithTenant($path) && $resolvedContext->pageCategory === TenantPageCategory::TenantBound)
! $this->isWorkspaceScopedPageWithTenant($path)
&& $resolvedContext->pageCategory === TenantPageCategory::TenantBound
)
) {
Filament::setTenant($resolvedContext->tenant, true);
@ -116,9 +131,7 @@ public function handle(Request $request, Closure $next): Response
}
if (
str_starts_with($path, '/admin/w/')
|| str_starts_with($path, '/admin/workspaces')
|| str_starts_with($path, '/admin/operations')
str_starts_with($path, '/admin/workspaces/')
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)
) {
$this->configureNavigationForRequest($panel);
@ -181,7 +194,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
)
->item(
NavigationItem::make('Operations')
->url(fn (): string => route('admin.operations.index'))
->url(fn (): string => OperationRunLinks::index())
->icon('heroicon-o-queue-list')
->group('Monitoring')
->sort(10),
@ -229,7 +242,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
private function isWorkspaceScopedPageWithTenant(string $path): bool
{
return preg_match('#^/admin/tenants/[^/]+/required-permissions$#', $path) === 1;
return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/required-permissions$#', $path) === 1;
}
private function isLivewireUpdatePath(string $path): bool

View File

@ -249,7 +249,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId)
->where('managed_environment_id', (int) $tenant->getKey())
->exists()
? ['label' => 'Open backup set', 'url' => BackupSetResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
? ['label' => 'Open backup set', 'url' => BackupSetResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)]
: null,
'restore_run' => $tenant instanceof ManagedEnvironment
&& $this->capabilityResolver->isMember($user, $tenant)
@ -258,7 +258,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId)
->where('managed_environment_id', (int) $tenant->getKey())
->exists()
? ['label' => 'Open restore run', 'url' => RestoreRunResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
? ['label' => 'Open restore run', 'url' => RestoreRunResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)]
: null,
'finding' => $tenant instanceof ManagedEnvironment
&& $this->capabilityResolver->isMember($user, $tenant)
@ -267,7 +267,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId)
->where('managed_environment_id', (int) $tenant->getKey())
->exists()
? ['label' => 'Open finding', 'url' => FindingResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
? ['label' => 'Open finding', 'url' => FindingResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)]
: null,
'finding_exception' => $tenant instanceof ManagedEnvironment
&& $this->capabilityResolver->isMember($user, $tenant)
@ -276,7 +276,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId)
->where('managed_environment_id', (int) $tenant->getKey())
->first()) instanceof FindingException
? ['label' => 'Open finding exception', 'url' => FindingExceptionResource::getUrl('view', ['record' => $findingException], panel: 'tenant', tenant: $tenant)]
? ['label' => 'Open finding exception', 'url' => FindingExceptionResource::getUrl('view', ['record' => $findingException], tenant: $tenant)]
: null,
default => null,
};

View File

@ -48,7 +48,7 @@ public function returnAffordance(?Request $request = null): ?array
if ($activeTenant instanceof ManagedEnvironment) {
return [
'label' => 'Back to '.$activeTenant->name,
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
'url' => TenantDashboard::getUrl(tenant: $activeTenant),
];
}

View File

@ -19,7 +19,9 @@
use App\Models\ReviewPack;
use App\Models\ManagedEnvironment;
use App\Models\TenantReview;
use App\Models\Workspace;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
final class OperationRunLinks
{
@ -83,8 +85,16 @@ public static function index(
?string $problemClass = null,
?string $operationType = null,
): string {
$workspace = self::resolveWorkspace($tenant);
if (! $workspace instanceof Workspace) {
return url('/admin');
}
$parameters = $context?->toQuery() ?? [];
$parameters['workspace'] = $workspace;
if ($tenant instanceof ManagedEnvironment) {
$parameters['managed_environment_id'] = (int) $tenant->getKey();
} elseif ($allTenants) {
@ -118,8 +128,14 @@ public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigatio
{
$runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run;
$workspace = self::resolveWorkspace($run);
if (! $workspace instanceof Workspace) {
return url('/admin');
}
return route('admin.operations.view', array_merge(
['run' => $runId],
['workspace' => $workspace, 'run' => $runId],
$context?->toQuery() ?? [],
));
}
@ -153,15 +169,15 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
}
if ($canonicalType === 'inventory.sync') {
$links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Inventory'] = InventoryItemResource::getUrl('index', tenant: $tenant);
}
if ($canonicalType === 'policy.sync') {
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant);
$policyId = $context['policy_id'] ?? null;
if (is_numeric($policyId)) {
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant);
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant);
}
}
@ -170,7 +186,7 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
}
if ($canonicalType === 'baseline.compare') {
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
$links['Drift'] = BaselineCompareLanding::getUrl(tenant: $tenant);
}
if ($canonicalType === 'baseline.capture') {
@ -182,24 +198,24 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
}
if ($canonicalType === 'backup_set.update') {
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
$backupSetId = $context['backup_set_id'] ?? null;
if (is_numeric($backupSetId)) {
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant);
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant);
}
}
if (in_array($canonicalType, ['backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge'], true)) {
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
}
if ($canonicalType === 'restore.execute') {
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant);
$restoreRunId = $context['restore_run_id'] ?? null;
if (is_numeric($restoreRunId)) {
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant);
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant);
}
}
@ -238,4 +254,28 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
}
private static function resolveWorkspace(ManagedEnvironment|OperationRun|int|null $subject = null): ?Workspace
{
if ($subject instanceof ManagedEnvironment) {
return $subject->workspace()->first();
}
if ($subject instanceof OperationRun) {
return Workspace::query()->whereKey((int) $subject->workspace_id)->first();
}
if (is_int($subject) && $subject > 0) {
$run = OperationRun::query()
->select(['id', 'workspace_id'])
->whereKey($subject)
->first();
if ($run instanceof OperationRun) {
return Workspace::query()->whereKey((int) $run->workspace_id)->first();
}
}
return app(WorkspaceContext::class)->currentWorkspace(request());
}
}

View File

@ -102,7 +102,6 @@ public static function findingDatabaseNotificationMessage(Finding $finding, Mana
actionUrl: FindingResource::getUrl(
'view',
['record' => $finding],
panel: 'tenant',
tenant: $tenant,
),
actionTarget: 'finding_detail',

View File

@ -326,7 +326,7 @@ private function backupNextStepTarget(ManagedEnvironment $tenant, string $concer
'label' => $label,
'url' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'disabled' => false,
'helperText' => null,
];
@ -403,7 +403,7 @@ private function recoveryNextStepTarget(
'url' => RestoreRunResource::getUrl('view', [
'record' => $latestRunId,
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'disabled' => false,
'helperText' => null,
];
@ -414,7 +414,7 @@ private function recoveryNextStepTarget(
'label' => 'Open restore history',
'url' => RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'disabled' => false,
'helperText' => 'The latest restore detail is no longer available.',
];
@ -425,7 +425,7 @@ private function recoveryNextStepTarget(
'label' => 'Open restore history',
'url' => RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'disabled' => false,
'helperText' => null,
];

View File

@ -20,6 +20,7 @@ public function __construct(
public readonly string $verificationState,
public readonly string $readinessSummary,
public readonly array $contextualIdentityDetails = [],
public readonly bool $isEnabled = true,
) {}
public static function forConnection(ProviderConnection $connection): self
@ -41,6 +42,7 @@ public static function forConnection(ProviderConnection $connection): self
verificationState: $verificationState,
),
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
isEnabled: (bool) $connection->is_enabled,
);
}
@ -67,7 +69,10 @@ public function contextualIdentityLine(): ?string
* consent_state: string,
* verification_state: string,
* readiness_summary: string,
* contextual_identity_details: list<array<string, string>>
* target_scope_summary: string,
* provider_context: array{provider: string, details: list<array<string, string>>},
* contextual_identity_line: ?string,
* is_enabled: bool
* }
*/
public function toArray(): array
@ -78,7 +83,21 @@ public function toArray(): array
'consent_state' => $this->consentState,
'verification_state' => $this->verificationState,
'readiness_summary' => $this->readinessSummary,
'contextual_identity_details' => array_map(
'target_scope_summary' => $this->targetScopeSummary(),
'provider_context' => $this->providerContext(),
'contextual_identity_line' => $this->contextualIdentityLine(),
'is_enabled' => $this->isEnabled,
];
}
/**
* @return array{provider: string, details: list<array<string, string>>}
*/
public function providerContext(): array
{
return [
'provider' => $this->provider,
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$this->contextualIdentityDetails,
),

View File

@ -144,13 +144,31 @@ public function auditMetadataForConnection(ProviderConnection $connection, array
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'target_scope' => $summary->targetScope->toArray(),
'provider_identity_context' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$summary->contextualIdentityDetails,
),
'provider_context' => [
'provider' => (string) $connection->provider,
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$summary->contextualIdentityDetails,
),
],
], $extra);
}
/**
* @param list<ProviderIdentityContextMetadata> $details
* @return array{provider: string, details: list<array<string, string>>}
*/
public function providerContext(string $provider, array $details): array
{
return [
'provider' => $provider,
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$details,
),
];
}
/**
* @param list<string> $fields
* @return list<string>

View File

@ -58,7 +58,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
secondaryLabel: 'Backup set #'.$backupSet->getKey(),
linkTarget: new ReferenceLinkTarget(
targetKind: ReferenceClass::BackupSet->value,
url: BackupSetResource::getUrl('view', ['record' => $backupSet], panel: 'tenant', tenant: $backupSet->tenant),
url: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant),
actionLabel: 'View backup set',
contextBadge: 'ManagedEnvironment',
),

View File

@ -657,7 +657,7 @@ private function findingsSection(Collection $findings, ?ManagedEnvironment $tena
label: sprintf('%s finding #%d', ucfirst(str_replace('_', ' ', (string) $finding->severity)), (int) $finding->getKey()),
actionLabel: 'Open finding',
url: $tenant instanceof ManagedEnvironment
? FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)
? FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)
: null,
freshnessAt: $finding->last_seen_at,
))
@ -776,7 +776,7 @@ private function reviewPackSection(?ReviewPack $pack, ?ManagedEnvironment $tenan
label: 'Review pack #'.$pack->getKey(),
actionLabel: 'Open review pack',
url: $tenant instanceof ManagedEnvironment
? ReviewPackResource::getUrl('view', ['record' => $pack], panel: 'tenant', tenant: $tenant)
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant)
: null,
freshnessAt: $pack->generated_at,
),
@ -905,7 +905,7 @@ private function tenantReference(ManagedEnvironment $tenant): array
'record_id' => (string) $tenant->getKey(),
'label' => $tenant->name,
'action_label' => 'Open tenant',
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
'url' => TenantDashboard::getUrl(tenant: $tenant),
'availability' => 'available',
'freshness_note' => null,
'access_reason' => null,

View File

@ -13,6 +13,7 @@
* @param list<array<string, mixed>> $recommendedActions
* @param list<array<string, mixed>> $governanceStatus
* @param list<array<string, mixed>> $readinessCards
* @param array<string, mixed>|null $activeOperationSummary
* @param list<array<string, mixed>> $recentOperations
*/
public function __construct(
@ -22,6 +23,7 @@ public function __construct(
public array $recommendedActions,
public array $governanceStatus,
public array $readinessCards,
public ?array $activeOperationSummary,
public array $recentOperations,
public ?string $pollingInterval,
) {}
@ -34,6 +36,7 @@ public function __construct(
* recommendedActions: list<array<string, mixed>>,
* governanceStatus: list<array<string, mixed>>,
* readinessCards: list<array<string, mixed>>,
* activeOperationSummary: array<string, mixed>|null,
* recentOperations: list<array<string, mixed>>,
* pollingInterval: ?string,
* }
@ -47,6 +50,7 @@ public function toArray(): array
'recommendedActions' => $this->recommendedActions,
'governanceStatus' => $this->governanceStatus,
'readinessCards' => $this->readinessCards,
'activeOperationSummary' => $this->activeOperationSummary,
'recentOperations' => $this->recentOperations,
'pollingInterval' => $this->pollingInterval,
];

View File

@ -33,6 +33,7 @@
use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OperationUxPresenter;
@ -42,6 +43,7 @@
use App\Support\Verification\VerificationReportOverall;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class TenantDashboardSummaryBuilder
@ -142,6 +144,7 @@ public function build(ManagedEnvironment $tenant, ?User $user = null): TenantDas
latestEvidenceSnapshot: $latestEvidenceSnapshot,
exceptionStats: $exceptionStats,
),
activeOperationSummary: $this->activeOperationSummary($tenant, $user),
recentOperations: $this->recentOperationCards($tenant, $recentOperations),
pollingInterval: ActiveRuns::pollingIntervalForTenant($tenant),
);
@ -370,12 +373,7 @@ private function kpis(ManagedEnvironment $tenant, ?User $user, TenantGovernanceA
$highSeverityChart = $this->highSeverityFindingsChart($tenant);
$operationsFollowUpChart = $this->operationsFollowUpChart($tenant);
$operationsNeedingFollowUp = (int) OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where(function ($query): void {
$query->terminalFollowUp()->orWhere(fn ($inner) => $inner->activeStaleAttention());
})
->count();
$operationsNeedingFollowUp = (int) $this->operationsRequiringAttentionQuery($tenant)->count();
return [
$this->metricCard(
@ -424,7 +422,7 @@ private function kpis(ManagedEnvironment $tenant, ?User $user, TenantGovernanceA
action: $this->operationsAction(
tenant: $tenant,
user: $user,
label: $this->overviewText('action_view_all_operations'),
label: $this->overviewText('action_open_operations_hub'),
activeTab: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : 'active',
problemClass: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : null,
),
@ -497,13 +495,8 @@ private function activeOperationsKpiDescription(int $count, ?array $chart): stri
return $this->overviewText('kpi_active_operations_tendency_none');
}
$windowCount = $chart === null ? 0 : array_sum($chart);
if ($windowCount > 0) {
return $this->overviewText('kpi_active_operations_tendency_window', [
'count' => $count,
'window' => $windowCount,
]);
if ($count === 1) {
return $this->overviewText('kpi_active_operations_tendency_one');
}
return $this->overviewText('kpi_active_operations_tendency', ['count' => $count]);
@ -548,9 +541,7 @@ private function highSeverityFindingsChart(ManagedEnvironment $tenant): ?array
private function operationsFollowUpChart(ManagedEnvironment $tenant): ?array
{
$window = $this->sevenDayWindow();
$byDay = OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->dashboardNeedsFollowUp()
$byDay = $this->operationsRequiringAttentionQuery($tenant)
->where(function (Builder $query) use ($window): void {
$query
->whereBetween('completed_at', [$window['start'], $window['end']])
@ -628,21 +619,21 @@ private function recommendedActions(
$candidates[] = $this->actionCandidate(
priority: 10,
key: 'required_permissions',
title: $this->overviewText('action_open_required_permissions'),
title: $this->overviewText('action_review_permissions'),
reason: $this->overviewText('reason_missing_application_permissions', ['count' => $missingApplicationPermissions]),
impact: $this->overviewText('impact_missing_application_permissions'),
tone: 'danger',
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_review_permissions')),
);
} elseif ($missingDelegatedPermissions > 0) {
$candidates[] = $this->actionCandidate(
priority: 20,
key: 'delegated_permissions',
title: $this->overviewText('action_open_required_permissions'),
title: $this->overviewText('action_review_permissions'),
reason: $this->overviewText('reason_missing_delegated_permissions', ['count' => $missingDelegatedPermissions]),
impact: $this->overviewText('impact_missing_delegated_permissions'),
tone: 'warning',
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_review_permissions')),
);
}
@ -701,25 +692,24 @@ private function recommendedActions(
);
}
$terminalFollowUpRuns = (int) OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->terminalFollowUp()
->count();
$operationsRequiringAttention = $this->operationsRequiringAttentionRuns($tenant);
if ($operationsRequiringAttention->isNotEmpty()) {
$dominantProblemClass = $this->dominantAttentionProblemClass($operationsRequiringAttention);
if ($terminalFollowUpRuns > 0) {
$candidates[] = $this->actionCandidate(
priority: 70,
key: 'terminal_operations',
title: $this->overviewText('action_view_all_operations'),
reason: $this->overviewText('reason_terminal_operations', ['count' => $terminalFollowUpRuns]),
impact: $this->overviewText('impact_terminal_operations'),
priority: 35,
key: 'operations_requiring_attention',
title: $this->overviewText('action_review_operations_requiring_attention'),
reason: $this->overviewText('reason_operations_requiring_attention'),
impact: $this->overviewText('impact_operations_requiring_attention'),
tone: 'danger',
action: $this->operationsAction(
tenant: $tenant,
user: $user,
label: $this->overviewText('action_view_all_operations'),
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
label: $this->overviewText('action_review_operations'),
activeTab: $dominantProblemClass,
problemClass: $dominantProblemClass,
),
);
}
@ -993,6 +983,68 @@ private function recentOperationCards(ManagedEnvironment $tenant, array $recentO
->all();
}
/**
* @return array<string, mixed>|null
*/
private function activeOperationSummary(ManagedEnvironment $tenant, ?User $user): ?array
{
if (! $user instanceof User || ! $user->canAccessTenant($tenant)) {
return null;
}
$qualifyingRuns = $this->operationsRequiringAttentionRuns($tenant);
if ($qualifyingRuns->isEmpty()) {
return null;
}
$dominantProblemClass = $this->dominantAttentionProblemClass($qualifyingRuns);
return [
'title' => $this->overviewText('operations_attention_title'),
'count' => $qualifyingRuns->count(),
'tone' => 'warning',
'secondaryActionLabel' => $this->overviewText('action_open_operations_hub'),
'secondaryActionUrl' => OperationRunLinks::index(
$tenant,
activeTab: $dominantProblemClass,
problemClass: $dominantProblemClass,
),
'items' => $this->attentionOperationItems($qualifyingRuns, $tenant),
];
}
private function compareActiveOperationSummaryRuns(OperationRun $left, OperationRun $right): int
{
$priorityComparison = $this->activeOperationSummaryPriority($left) <=> $this->activeOperationSummaryPriority($right);
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$timestampComparison = $this->activeOperationSummaryTimestamp($right) <=> $this->activeOperationSummaryTimestamp($left);
if ($timestampComparison !== 0) {
return $timestampComparison;
}
return ((int) $right->getKey()) <=> ((int) $left->getKey());
}
private function activeOperationSummaryPriority(OperationRun $run): int
{
return match ($run->problemClass()) {
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 0,
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 1,
default => 2,
};
}
private function activeOperationSummaryTimestamp(OperationRun $run): int
{
return ($run->completed_at ?? $run->started_at ?? $run->created_at)?->getTimestamp() ?? 0;
}
private function governanceStatusIcon(string $key): string
{
return match ($key) {
@ -1067,7 +1119,7 @@ private function recommendedActionIcon(string $key): string
return match ($key) {
'required_permissions', 'delegated_permissions', 'high_severity_findings' => 'heroicon-m-shield-exclamation',
'overdue_findings' => 'heroicon-o-clock',
'recovery_posture', 'terminal_operations', 'continue_review' => 'heroicon-o-arrow-path-rounded-square',
'recovery_posture', 'operations_requiring_attention', 'continue_review' => 'heroicon-o-arrow-path-rounded-square',
'risk_exceptions' => 'heroicon-o-exclamation-triangle',
default => 'heroicon-o-exclamation-triangle',
};
@ -1083,7 +1135,7 @@ private function tenantFindingsAction(ManagedEnvironment $tenant, ?User $user, s
return $this->actionPayload(
label: $label,
url: $canOpen ? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant) : null,
url: $canOpen ? FindingResource::getUrl('index', $parameters, tenant: $tenant) : null,
helperText: $canOpen ? null : $this->overviewText('helper_findings_requires_permissions'),
);
}
@ -1097,7 +1149,7 @@ private function riskExceptionsAction(ManagedEnvironment $tenant, ?User $user, s
return $this->actionPayload(
label: $label,
url: $canOpen ? FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant) : null,
url: $canOpen ? FindingExceptionResource::getUrl('index', tenant: $tenant) : null,
helperText: $canOpen ? null : $this->overviewText('helper_risk_exceptions_requires_permissions'),
);
}
@ -1149,8 +1201,8 @@ private function evidenceAction(ManagedEnvironment $tenant, ?User $user, string
if ($canOpen) {
$url = $snapshot instanceof EvidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'tenant', tenant: $tenant)
: EvidenceSnapshotResource::getUrl('index', panel: 'tenant', tenant: $tenant);
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant)
: EvidenceSnapshotResource::getUrl('index', tenant: $tenant);
}
return $this->actionPayload(
@ -1181,7 +1233,7 @@ private function customerWorkspaceAction(ManagedEnvironment $tenant, ?User $user
if ($canOpenWorkspace) {
$url = $reviewPack instanceof ReviewPack && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)
? ReviewPackResource::getUrl('view', ['record' => $reviewPack], panel: 'tenant', tenant: $tenant)
? ReviewPackResource::getUrl('view', ['record' => $reviewPack], tenant: $tenant)
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
}
@ -1220,6 +1272,170 @@ private function operationsAction(ManagedEnvironment $tenant, ?User $user, strin
);
}
private function operationsRequiringAttentionQuery(ManagedEnvironment $tenant): Builder
{
return OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->dashboardNeedsFollowUp();
}
/**
* @return Collection<int, OperationRun>
*/
private function operationsRequiringAttentionRuns(ManagedEnvironment $tenant): Collection
{
return $this->operationsRequiringAttentionQuery($tenant)
->get()
->sort(fn (OperationRun $left, OperationRun $right): int => $this->compareActiveOperationSummaryRuns($left, $right))
->values();
}
/**
* @param Collection<int, OperationRun> $runs
* @return list<array<string, mixed>>
*/
private function attentionOperationItems(Collection $runs, ManagedEnvironment $tenant): array
{
return $runs
->take(3)
->map(function (OperationRun $run) use ($tenant): array {
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
]);
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $run->outcome,
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
]);
return [
'id' => (int) $run->getKey(),
'identifier' => OperationRunLinks::identifier($run),
'type' => OperationCatalog::label((string) $run->type),
'title' => $this->attentionOperationTitle($run),
'icon' => $this->recentOperationIcon((string) $run->type),
'attentionLabel' => $this->attentionOperationBadgeLabel($run),
'problemClass' => $run->problemClass(),
'problemClassLabel' => OperationUxPresenter::problemClassLabel($run),
'statusLabel' => $statusSpec->label,
'statusTone' => $statusSpec->color,
'outcomeLabel' => $outcomeSpec->label,
'outcomeTone' => $outcomeSpec->color,
'outcomeSentence' => $this->attentionOperationOutcomeSentence($run),
'reason' => $this->attentionOperationReason($run),
'impact' => $this->attentionOperationImpact($run),
'timingLabel' => $this->attentionOperationTimingLabel($run),
'createdAt' => $run->completed_at?->diffForHumans() ?? $run->created_at?->diffForHumans(),
'primaryActionLabel' => $this->overviewText('action_review_operation'),
'primaryActionUrl' => OperationRunLinks::view($run, $tenant),
];
})
->values()
->all();
}
/**
* @param Collection<int, OperationRun> $runs
*/
private function dominantAttentionProblemClass(Collection $runs): string
{
return $runs->contains(fn (OperationRun $run): bool => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
}
private function attentionOperationTitle(OperationRun $run): string
{
return OperationCatalog::label((string) $run->type);
}
private function attentionOperationBadgeLabel(OperationRun $run): string
{
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
? $this->overviewText('operations_attention_badge_stale')
: $this->overviewText('operations_attention_badge_follow_up');
}
private function attentionOperationOutcomeSentence(OperationRun $run): string
{
if ($run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION) {
return $this->overviewText('operations_attention_outcome_stale');
}
if ($this->isProviderConsentBlockedRun($run)) {
return $this->overviewText('operations_attention_outcome_provider_consent_required');
}
return match ((string) $run->outcome) {
OperationRunOutcome::Blocked->value => $this->overviewText('operations_attention_outcome_blocked'),
OperationRunOutcome::PartiallySucceeded->value => $this->overviewText('operations_attention_outcome_partial'),
OperationRunOutcome::Failed->value => $this->overviewText('operations_attention_outcome_failed'),
default => $this->overviewText('operations_attention_outcome_generic'),
};
}
private function attentionOperationReason(OperationRun $run): string
{
if ($this->isProviderConsentBlockedRun($run)) {
return $this->overviewText('operations_attention_reason_provider_consent_required');
}
$operatorExplanation = OperationUxPresenter::governanceOperatorExplanation($run);
$reason = trim((string) ($operatorExplanation?->dominantCauseExplanation ?? ''));
if ($reason !== '') {
return $reason;
}
$failureDetail = trim((string) (OperationUxPresenter::surfaceFailureDetail($run) ?? ''));
if ($failureDetail !== '') {
return $failureDetail;
}
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
? $this->overviewText('operations_attention_reason_stale')
: $this->overviewText('operations_attention_reason_fallback');
}
private function attentionOperationImpact(OperationRun $run): string
{
if ($this->isProviderConsentBlockedRun($run)) {
return $this->overviewText('operations_attention_impact_provider_consent_required');
}
return $run->problemClass() === OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION
? $this->overviewText('operations_attention_impact_stale')
: $this->overviewText('operations_attention_impact_follow_up');
}
private function attentionOperationTimingLabel(OperationRun $run): ?string
{
if ($run->completed_at instanceof Carbon) {
return $this->overviewText('operations_attention_timing_completed', [
'time' => $run->completed_at->diffForHumans(),
]);
}
$reference = $run->started_at ?? $run->created_at;
if (! $reference instanceof Carbon) {
return null;
}
return $this->overviewText('operations_attention_timing_started', [
'time' => $reference->diffForHumans(),
]);
}
private function isProviderConsentBlockedRun(OperationRun $run): bool
{
return OperationCatalog::canonicalCode((string) $run->type) === OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK
&& (string) $run->outcome === OperationRunOutcome::Blocked->value;
}
/**
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
*/
@ -1229,7 +1445,7 @@ private function baselineCompareAction(ManagedEnvironment $tenant, ?User $user,
return $this->actionPayload(
label: $label,
url: $canOpen ? BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant) : null,
url: $canOpen ? BaselineCompareLanding::getUrl(tenant: $tenant) : null,
helperText: $canOpen ? null : $this->overviewText('helper_baseline_compare_requires_permissions'),
);
}
@ -1257,10 +1473,10 @@ private function backupHealthAction(ManagedEnvironment $tenant, ?User $user, str
$url = match ($target->surface) {
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $target->recordId !== null
? BackupSetResource::getUrl('view', ['record' => $target->recordId], panel: 'tenant', tenant: $tenant)
: BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant),
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant),
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant),
? BackupSetResource::getUrl('view', ['record' => $target->recordId], tenant: $tenant)
: BackupSetResource::getUrl('index', tenant: $tenant),
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => BackupSetResource::getUrl('index', tenant: $tenant),
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => BackupScheduleResource::getUrl('index', tenant: $tenant),
default => null,
};

View File

@ -13,6 +13,8 @@ public function __construct(
public ?int $tenantId,
public ?string $tenantName,
public TenantLifecyclePresentation $presentation,
public bool $removedFromWorkspace,
public bool $workspaceClosed,
public ?string $contextNote,
) {}
@ -30,7 +32,9 @@ public static function fromTenant(ManagedEnvironment $tenant, string $viewerCont
tenantId: (int) $tenant->getKey(),
tenantName: $tenant->name,
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,
tenantName: $tenant?->name,
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.',
);
}
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) {
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;
}
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()) {
return 'Some tenant follow-up actions may be unavailable from this canonical workspace view.';
}

View File

@ -22,6 +22,8 @@ enum TenantOperabilityReasonCode: string
case OnboardingNotResumable = 'onboarding_not_resumable';
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
case RememberedContextStale = 'remembered_context_stale';
case WorkspaceClosed = 'workspace_closed';
case TenantRemovedFromWorkspace = 'tenant_removed_from_workspace';
public function operatorLabel(): string
{
@ -36,6 +38,8 @@ public function operatorLabel(): string
self::OnboardingNotResumable => 'Onboarding cannot be resumed',
self::CanonicalViewFollowupOnly => 'Follow-up requires tenant context',
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::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::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) {
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',
};
}
@ -103,6 +109,12 @@ public function nextSteps(): array
self::SelectorIneligibleLifecycle, self::RememberedContextStale => [
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 => [
NextStepOption::instruction('Archive the tenant before retrying this action.', scope: 'tenant'),
],

View File

@ -32,7 +32,7 @@ public static function fromPath(string $path): self
return self::WorkspaceChooserException;
}
if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) {
if (preg_match('#^/admin/workspaces/[^/]+/operations/[^/]+$#', $normalizedPath) === 1) {
return self::CanonicalWorkspaceRecordViewer;
}
@ -47,10 +47,7 @@ public static function fromPath(string $path): self
return self::OnboardingWorkflow;
}
if (
preg_match('#^/admin/t/[^/]+(?:/|$)#', $normalizedPath) === 1
|| preg_match('#^/admin/tenants/[^/]+(?:/|$)#', $normalizedPath) === 1
) {
if (preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+(?:/|$)#', $normalizedPath) === 1) {
return self::TenantBound;
}

View File

@ -53,12 +53,17 @@ public static function identity(OperationRun $run): array
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
if ($targetScopeIdentifier !== null) {
$identity['target_scope'] = self::targetScopeIdentity($targetScope);
} else {
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
}
}
$connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null);
$connectionType = $context['connection_type'] ?? ($targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null));
if (is_string($connectionType) && trim($connectionType) !== '') {
$identity['connection_type'] = trim($connectionType);
}
@ -124,15 +129,23 @@ private static function evidence(OperationRun $run, array $context): array
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
if ($targetScopeIdentifier !== null) {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
'kind' => 'target_scope_identifier',
'value' => $targetScopeIdentifier,
];
} else {
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
];
}
}
$connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null);
$connectionType = $context['connection_type'] ?? ($targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null));
if (is_string($connectionType) && trim($connectionType) !== '') {
$evidence[] = [
'kind' => 'connection_type',
@ -163,4 +176,35 @@ private static function evidence(OperationRun $run, array $context): array
return $evidence;
}
/**
* @param array<string, mixed> $targetScope
*/
private static function targetScopeIdentifier(array $targetScope): ?string
{
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
return is_string($scopeIdentifier) && trim($scopeIdentifier) !== ''
? trim($scopeIdentifier)
: null;
}
/**
* @param array<string, mixed> $targetScope
* @return array<string, string>
*/
private static function targetScopeIdentity(array $targetScope): array
{
$identity = [];
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
$value = $targetScope[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
$identity[$key] = trim($value);
}
}
return $identity;
}
}

View File

@ -45,9 +45,14 @@ public static function identity(OperationRun $run): array
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
if ($targetScopeIdentifier !== null) {
$identity['target_scope'] = self::targetScopeIdentity($targetScope);
} else {
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
}
}
return $identity;
@ -72,12 +77,20 @@ private static function evidence(OperationRun $run, array $context): array
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
if ($targetScopeIdentifier !== null) {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
'kind' => 'target_scope_identifier',
'value' => $targetScopeIdentifier,
];
} else {
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
];
}
}
$evidence[] = [
@ -87,4 +100,35 @@ private static function evidence(OperationRun $run, array $context): array
return $evidence;
}
/**
* @param array<string, mixed> $targetScope
*/
private static function targetScopeIdentifier(array $targetScope): ?string
{
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
return is_string($scopeIdentifier) && trim($scopeIdentifier) !== ''
? trim($scopeIdentifier)
: null;
}
/**
* @param array<string, mixed> $targetScope
* @return array<string, string>
*/
private static function targetScopeIdentity(array $targetScope): array
{
$identity = [];
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
$value = $targetScope[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
$identity[$key] = trim($value);
}
}
return $identity;
}
}

View File

@ -142,7 +142,7 @@ private function isExternalUrl(string $url): bool
private function isInternalDiagnosticPath(string $path): bool
{
return (bool) preg_match(
'/^\/admin\/(?:tenants\/[^\/]+\/required-permissions|tenants\/[^\/]+\/provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?|provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?)$/',
'/^\/admin\/(?:workspaces\/[^\/]+\/environments\/[^\/]+\/required-permissions|tenants\/[^\/]+\/provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?|provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?)$/',
$path,
);
}

View File

@ -13,6 +13,7 @@ final class VerificationReportSanitizer
*/
private const ALLOWED_EVIDENCE_KINDS = [
'provider_connection_id',
'target_scope_identifier',
'entra_tenant_id',
'connection_type',
'credential_source',
@ -108,7 +109,7 @@ public static function sanitizeReport(array $report): array
/**
* @param array<string, mixed> $identity
* @return array<string, int|string>
* @return array<string, mixed>
*/
private static function sanitizeIdentity(array $identity): array
{
@ -123,6 +124,16 @@ private static function sanitizeIdentity(array $identity): array
continue;
}
if ($key === 'target_scope' && is_array($value)) {
$targetScope = self::sanitizeIdentityTargetScope($value);
if ($targetScope !== []) {
$sanitized[$key] = $targetScope;
}
continue;
}
if (is_int($value)) {
$sanitized[$key] = $value;
@ -143,6 +154,31 @@ private static function sanitizeIdentity(array $identity): array
return $sanitized;
}
/**
* @param array<string, mixed> $targetScope
* @return array<string, string>
*/
private static function sanitizeIdentityTargetScope(array $targetScope): array
{
$sanitized = [];
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
$value = $targetScope[$key] ?? null;
if (! is_string($value)) {
continue;
}
$value = self::sanitizeValueString($value);
if ($value !== null) {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
/**
* @param array<string, mixed> $summary
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null

View File

@ -84,6 +84,10 @@ public function currentWorkspaceOrTenantWorkspace(?ManagedEnvironment $tenant =
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->put(self::SESSION_KEY, (int) $workspace->getKey());
@ -316,7 +320,7 @@ public function ensureTenantAccessibleInCurrentWorkspace(ManagedEnvironment $ten
{
$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;
}
@ -325,7 +329,7 @@ public function ensureTenantAccessibleInCurrentWorkspace(ManagedEnvironment $ten
private function isWorkspaceSelectable(Workspace $workspace): bool
{
return empty($workspace->archived_at);
return $workspace->isSelectableAsContext();
}
private function userCanAccessTenant(ManagedEnvironment $tenant, ?Request $request = null): bool

View File

@ -1575,7 +1575,7 @@ private function tenantDashboardTarget(
return $this->destination(
kind: 'tenant_dashboard',
url: $this->appendArrivalToken(
TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
TenantDashboard::getUrl(tenant: $tenant),
$arrivalState,
),
label: $label,
@ -1640,7 +1640,7 @@ private function findingsTarget(ManagedEnvironment $tenant, User $user, array $f
if ($this->canOpenFindings($user, $tenant)) {
return $this->destination(
kind: 'tenant_findings',
url: FindingResource::getUrl('index', $filters, panel: 'tenant', tenant: $tenant),
url: FindingResource::getUrl('index', $filters, tenant: $tenant),
label: $label,
tenant: $tenant,
filters: $filters,
@ -1674,7 +1674,7 @@ private function baselineCompareTarget(ManagedEnvironment $tenant, User $user, s
return $this->destination(
kind: 'baseline_compare_landing',
url: BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
url: BaselineCompareLanding::getUrl(tenant: $tenant),
label: $label,
tenant: $tenant,
);

View File

@ -4,7 +4,6 @@
namespace App\Support\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\TenantDashboard;
use App\Models\ManagedEnvironment;
@ -45,20 +44,18 @@ public function resolve(Workspace $workspace, User $user, ?string $intendedUrl =
$tenantCount = (int) $selectableTenants->count();
if ($tenantCount === 0) {
return route('admin.workspace.managed-tenants.index', [
'workspace' => $workspace->slug ?? $workspace->getKey(),
]);
return $this->environmentChooserUrl($workspace);
}
if ($tenantCount === 1) {
$tenant = $selectableTenants->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
return TenantDashboard::getUrl(tenant: $tenant);
}
}
return ChooseTenant::getUrl();
return $this->environmentChooserUrl($workspace);
}
/**
@ -108,7 +105,7 @@ private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace
$query->where('slug', $identifier);
if (ctype_digit($identifier)) {
$query->orWhereKey((int) $identifier);
$query->orWhere((new ManagedEnvironment)->getQualifiedKeyName(), (int) $identifier);
}
})
->first();
@ -117,4 +114,9 @@ private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
&& $user->canAccessTenant($tenant);
}
private function environmentChooserUrl(Workspace $workspace): string
{
return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments');
}
}

View File

@ -4,6 +4,5 @@
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\Filament\TenantPanelProvider::class,
App\Providers\Filament\SystemPanelProvider::class,
];

View File

@ -40,18 +40,19 @@
'target scope',
'credential source',
'effective client identity',
'provider context',
],
'retained_provider_semantics' => [
'entra_tenant_id',
'provider_context.microsoft_tenant_id',
'platform_config',
'graph.tenant_id',
'admin.consent.callback',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
'provider.connection_resolution' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core provider connection selection and validation path that keeps current Microsoft connection details as bounded exception metadata.',
'description' => 'Platform-core provider connection selection and validation path that publishes neutral target-scope truth with provider-specific profile detail kept as bounded context metadata.',
'implementation_paths' => [
'app/Services/Providers/ProviderConnectionResolver.php',
'app/Services/Providers/ProviderConnectionResolution.php',
@ -59,16 +60,16 @@
'neutral_terms' => [
'provider',
'provider connection',
'tenant scope',
'target scope',
'default binding',
'unsupported combination',
],
'retained_provider_semantics' => [
'microsoft',
'entra_tenant_id',
'provider_context.microsoft_tenant_id',
'consent_status',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
'provider.operation_registry' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
@ -94,7 +95,7 @@
],
'provider.operation_start_gate' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records current Microsoft target-scope exceptions.',
'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records neutral target-scope context with provider-specific follow-up detail nested separately.',
'implementation_paths' => [
'app/Services/Providers/ProviderOperationStartGate.php',
],
@ -107,9 +108,9 @@
],
'retained_provider_semantics' => [
'microsoft',
'target_scope.entra_tenant_id',
'provider_context.microsoft_tenant_id',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
],
];

View File

@ -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']);
});
}
};

View File

@ -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',
]);
});
}
};

View File

@ -173,17 +173,25 @@
'kpi_missing_permissions_tendency_app_only' => ':count App-Berechtigungen fehlen',
'kpi_missing_permissions_tendency_delegated_only' => ':count delegierte Berechtigungen fehlen',
'kpi_missing_permissions_tendency_none' => 'Berechtigungen vollständig',
'kpi_active_operations_label' => 'Aktive Vorgänge',
'kpi_active_operations_description' => 'Veraltete oder terminale Vorgänge benötigen Operator-Nachverfolgung.',
'kpi_active_operations_tendency' => ':count mit Follow-up',
'kpi_active_operations_tendency_window' => ':count mit Follow-up · :window in 7 Tagen',
'kpi_active_operations_tendency_none' => 'Kein Follow-up offen',
'kpi_active_operations_label' => 'Vorgänge mit Aufmerksamkeitsbedarf',
'kpi_active_operations_description' => 'Vorgangsläufe, die noch Follow-up benötigen, bevor der Tenant als gesund gelten kann.',
'kpi_active_operations_tendency' => ':count Vorgänge erfordern Aufmerksamkeit',
'kpi_active_operations_tendency_window' => ':count Vorgänge erfordern Aufmerksamkeit',
'kpi_active_operations_tendency_one' => '1 Vorgang benötigt Follow-up',
'kpi_active_operations_tendency_none' => 'Keine Vorgänge benötigen Aufmerksamkeit',
'action_review_findings' => 'Findings prüfen',
'action_open_overdue_findings' => 'Überfällige Findings öffnen',
'action_review_permissions' => 'Berechtigungen prüfen',
'action_open_required_permissions' => 'Erforderliche Berechtigungen öffnen',
'action_review_risks' => 'Risiken prüfen',
'action_review_recovery_posture' => 'Wiederherstellungsstatus prüfen',
'action_view_all_operations' => 'Alle Vorgänge anzeigen',
'action_view_operation' => 'Vorgang anzeigen',
'action_review_operation' => 'Vorgang prüfen',
'action_review_operations' => 'Vorgänge prüfen',
'action_review_operations_requiring_attention' => 'Aufmerksamkeitspflichtige Vorgänge prüfen',
'action_open_operations_hub' => 'Operations-Hub öffnen',
'action_show_all_operations' => 'Alle Vorgänge anzeigen',
'action_open_governance_inbox' => 'Governance Inbox öffnen',
'action_continue_review' => 'Review fortsetzen',
'action_open_baseline_compare' => 'Baseline Compare öffnen',
@ -207,8 +215,27 @@
'impact_recovery_posture' => 'Die Wiederherstellungsbereitschaft sollte geprüft werden, bevor kundensichere Aussagen auf Backup- oder Restore-Vertrauen beruhen.',
'reason_terminal_operations' => ':count Vorgangslauf/Läufe endeten blockiert, teilweise oder fehlgeschlagen.',
'impact_terminal_operations' => 'Terminale Laufergebnisse benötigen Nachverfolgung, bevor der Tenant als ruhig gelten kann.',
'reason_operations_requiring_attention' => 'Ein oder mehrere Vorgänge endeten mit einem Ergebnis, das Follow-up benötigt.',
'impact_operations_requiring_attention' => 'Der Tenant sollte nicht als vollständig gesund betrachtet werden, bis das Vorgangsergebnis geprüft wurde.',
'reason_continue_review' => 'Kundensichere Ausgabe ist noch nicht vollständig bereit.',
'impact_continue_review' => 'Die Review-Ausgabe bleibt teilweise, bis Review-, Nachweis- und Paketflächen sauber zusammenpassen.',
'operations_attention_title' => 'Vorgänge mit Aufmerksamkeitsbedarf',
'operations_attention_badge_follow_up' => 'Follow-up erforderlich',
'operations_attention_badge_stale' => 'Aufmerksamkeit nötig',
'operations_attention_outcome_blocked' => 'Der Vorgang wurde beendet, aber eine Voraussetzung hat den Abschluss blockiert.',
'operations_attention_outcome_partial' => 'Der Vorgang wurde beendet, aber es ist weiterhin Follow-up erforderlich.',
'operations_attention_outcome_failed' => 'Der Vorgang endete mit einem Fehler, der geprüft werden muss.',
'operations_attention_outcome_generic' => 'Der Vorgang endete mit einem Ergebnis, das Nachverfolgung benötigt.',
'operations_attention_outcome_stale' => 'Der Vorgang ist noch aktiv, liegt aber außerhalb seines erwarteten Lebenszyklusfensters.',
'operations_attention_outcome_provider_consent_required' => 'Die Prüfung ist abgeschlossen, aber die Provider-Zustimmung ist noch erforderlich.',
'operations_attention_reason_fallback' => 'Das aufgezeichnete Ergebnis muss geprüft werden, bevor der Tenant als gesund gelten kann.',
'operations_attention_reason_stale' => 'Der Lauf liegt außerhalb seines normalen Lebenszyklusfensters und schreitet möglicherweise nicht mehr fort.',
'operations_attention_reason_provider_consent_required' => 'Eine Admin-Zustimmung ist erforderlich, bevor die Provider-Verbindung verwendet werden kann.',
'operations_attention_impact_follow_up' => 'Die Tenant-Bereitschaft sollte nicht als vollständig gesund betrachtet werden, bis das Vorgangsergebnis geprüft wurde.',
'operations_attention_impact_stale' => 'Die Tenant-Bereitschaft sollte nicht als aktuell betrachtet werden, bis der blockierte Lauf geprüft wurde.',
'operations_attention_impact_provider_consent_required' => 'Die Tenant-Bereitschaft kann nicht als gesund betrachtet werden, bis dies geprüft wurde.',
'operations_attention_timing_completed' => 'Abgeschlossen :time',
'operations_attention_timing_started' => 'Gestartet :time',
'governance_baseline_compare_label' => 'Baseline Compare',
'governance_baseline_compare_description' => 'Aktueller Compare-Status für die Tenant-Baseline.',
'governance_evidence_coverage_label' => 'Nachweisabdeckung',

View File

@ -173,17 +173,25 @@
'kpi_missing_permissions_tendency_app_only' => ':count app missing',
'kpi_missing_permissions_tendency_delegated_only' => ':count delegated missing',
'kpi_missing_permissions_tendency_none' => 'Permission set complete',
'kpi_active_operations_label' => 'Active operations',
'kpi_active_operations_description' => 'Stale or terminal operation runs needing operator follow-up.',
'kpi_active_operations_tendency' => ':count need follow-up',
'kpi_active_operations_tendency_window' => ':count need follow-up · :window in 7d',
'kpi_active_operations_tendency_none' => 'No follow-up queued',
'kpi_active_operations_label' => 'Operations needing attention',
'kpi_active_operations_description' => 'Operation runs that still need follow-up before the tenant can be treated as healthy.',
'kpi_active_operations_tendency' => ':count operations require attention',
'kpi_active_operations_tendency_window' => ':count operations require attention',
'kpi_active_operations_tendency_one' => '1 operation needs follow-up',
'kpi_active_operations_tendency_none' => 'No operations need attention',
'action_review_findings' => 'Review findings',
'action_open_overdue_findings' => 'Open overdue findings',
'action_review_permissions' => 'Review permissions',
'action_open_required_permissions' => 'Open required permissions',
'action_review_risks' => 'Review risks',
'action_review_recovery_posture' => 'Review recovery posture',
'action_view_all_operations' => 'View all operations',
'action_view_operation' => 'View operation',
'action_review_operation' => 'Review operation',
'action_review_operations' => 'Review operations',
'action_review_operations_requiring_attention' => 'Review operations requiring attention',
'action_open_operations_hub' => 'Open operations hub',
'action_show_all_operations' => 'Show all operations',
'action_open_governance_inbox' => 'Open governance inbox',
'action_continue_review' => 'Continue review',
'action_open_baseline_compare' => 'Open Baseline Compare',
@ -207,8 +215,27 @@
'impact_recovery_posture' => 'Recovery readiness should be checked before customer-safe claims rely on backup or restore confidence.',
'reason_terminal_operations' => ':count operation run(s) finished blocked, partial, or failed.',
'impact_terminal_operations' => 'Terminal run outcomes need follow-up before the tenant can be treated as calm.',
'reason_operations_requiring_attention' => 'One or more operations finished with an outcome that needs follow-up.',
'impact_operations_requiring_attention' => 'The tenant should not be treated as fully healthy until the operation outcome has been reviewed.',
'reason_continue_review' => 'Customer-safe output is not fully ready yet.',
'impact_continue_review' => 'Review output stays partial until the review, evidence, and pack surfaces line up cleanly.',
'operations_attention_title' => 'Operations requiring attention',
'operations_attention_badge_follow_up' => 'Follow-up required',
'operations_attention_badge_stale' => 'Needs attention',
'operations_attention_outcome_blocked' => 'The operation finished, but a prerequisite blocked completion.',
'operations_attention_outcome_partial' => 'The operation finished, but follow-up is still required.',
'operations_attention_outcome_failed' => 'The operation finished with a failure that needs review.',
'operations_attention_outcome_generic' => 'The operation finished with an outcome that needs follow-up.',
'operations_attention_outcome_stale' => 'The operation is still active, but it is past its expected lifecycle window.',
'operations_attention_outcome_provider_consent_required' => 'The check finished, but provider consent is still required.',
'operations_attention_reason_fallback' => 'The recorded outcome still needs operator review before the tenant can be treated as healthy.',
'operations_attention_reason_stale' => 'The run is past its normal lifecycle window and may no longer be progressing.',
'operations_attention_reason_provider_consent_required' => 'Admin consent is required before the provider connection can be used.',
'operations_attention_impact_follow_up' => 'Tenant readiness should not be treated as fully healthy until the operation outcome has been reviewed.',
'operations_attention_impact_stale' => 'Tenant readiness should not be treated as current until the stalled run has been reviewed.',
'operations_attention_impact_provider_consent_required' => 'Tenant readiness cannot be treated as healthy until this is reviewed.',
'operations_attention_timing_completed' => 'Completed :time',
'operations_attention_timing_started' => 'Started :time',
'governance_baseline_compare_label' => 'Baseline compare',
'governance_baseline_compare_description' => 'Current compare posture for the tenant baseline.',
'governance_evidence_coverage_label' => 'Evidence coverage',

View File

@ -14,6 +14,7 @@
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
$targetScopeSummary = is_string($state['target_scope_summary'] ?? null) ? (string) $state['target_scope_summary'] : null;
$isMissing = $connectionState === 'missing';
$lifecycleSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $isEnabled ?? $lifecycle);
@ -26,9 +27,9 @@
<div>
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
@if ($isMissing)
<div class="mt-1 text-sm text-amber-700">Needs action: no Microsoft provider connection is configured.</div>
<div class="mt-1 text-sm text-amber-700">Needs action: no provider connection is configured.</div>
@elseif ($needsDefaultConnection)
<div class="mt-1 text-sm text-amber-700">Needs action: set a default Microsoft provider connection.</div>
<div class="mt-1 text-sm text-amber-700">Needs action: set a default provider connection.</div>
@else
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
@endif
@ -51,6 +52,10 @@
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
<dd>{{ $provider ?? 'n/a' }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Target scope</dt>
<dd>{{ $targetScopeSummary ?? 'n/a' }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Lifecycle</dt>
<dd>

View File

@ -47,7 +47,7 @@ class="h-6 w-6 text-gray-400 dark:text-gray-500"
</div>
<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">
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>
</div>
</div>

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