Compare commits
6 Commits
40a33cdc15
...
e4db44f606
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4db44f606 | ||
|
|
35156a72bc | ||
| 023274c46c | |||
| 2952e5ad3e | |||
| 210508db9d | |||
| 670c46dedd |
@ -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'],
|
||||
],
|
||||
);
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ protected function getViewData(): array
|
||||
'recommendedActions' => [],
|
||||
'governanceStatus' => [],
|
||||
'readinessCards' => [],
|
||||
'activeOperationSummary' => null,
|
||||
'recentOperations' => [],
|
||||
'pollingInterval' => null,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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),
|
||||
]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
]),
|
||||
|
||||
@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Workspaces;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class WorkspaceLifecycleService
|
||||
{
|
||||
public const WORKSPACE_POSTURE_OPEN = 'open';
|
||||
|
||||
public const WORKSPACE_POSTURE_CLOSED = 'closed';
|
||||
|
||||
public const TENANT_POSTURE_ACTIVE = 'active';
|
||||
|
||||
public const TENANT_POSTURE_REMOVED = 'removed_from_workspace';
|
||||
|
||||
public function __construct(
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
) {}
|
||||
|
||||
public function workspacePosture(Workspace $workspace): string
|
||||
{
|
||||
return $workspace->isClosed()
|
||||
? self::WORKSPACE_POSTURE_CLOSED
|
||||
: self::WORKSPACE_POSTURE_OPEN;
|
||||
}
|
||||
|
||||
public function tenantPosture(ManagedEnvironment $tenant): string
|
||||
{
|
||||
return $tenant->isRemovedFromWorkspace()
|
||||
? self::TENANT_POSTURE_REMOVED
|
||||
: self::TENANT_POSTURE_ACTIVE;
|
||||
}
|
||||
|
||||
public function assertWorkspaceMutationAllowed(Workspace $workspace): void
|
||||
{
|
||||
if (! $workspace->isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'workspace' => 'This workspace is closed. Reopen it before making workspace or tenant changes.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function closeWorkspace(Workspace $workspace, PlatformUser $actor, string $reason): Workspace
|
||||
{
|
||||
$this->authorizePlatformDirectoryManagement($actor);
|
||||
$reason = $this->normalizeReason($reason);
|
||||
|
||||
return DB::transaction(function () use ($workspace, $actor, $reason): Workspace {
|
||||
$workspace = Workspace::query()->lockForUpdate()->findOrFail($workspace->getKey());
|
||||
|
||||
if ($workspace->isClosed()) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'This workspace is already closed.',
|
||||
]);
|
||||
}
|
||||
|
||||
$workspace->forceFill([
|
||||
'closed_at' => now(),
|
||||
'closed_by_platform_user_id' => (int) $actor->getKey(),
|
||||
'closed_reason' => $reason,
|
||||
])->save();
|
||||
|
||||
ManagedEnvironment::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('is_current', true)
|
||||
->update(['is_current' => false]);
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceClosed,
|
||||
context: [
|
||||
'reason' => $reason,
|
||||
'before_status' => self::WORKSPACE_POSTURE_OPEN,
|
||||
'after_status' => self::WORKSPACE_POSTURE_CLOSED,
|
||||
'closed_at' => $workspace->closed_at?->toISOString(),
|
||||
],
|
||||
actor: $actor,
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
targetLabel: (string) $workspace->name,
|
||||
summary: 'Workspace closed for '.$workspace->name,
|
||||
);
|
||||
|
||||
return $workspace;
|
||||
});
|
||||
}
|
||||
|
||||
public function reopenWorkspace(Workspace $workspace, PlatformUser $actor, string $reason): Workspace
|
||||
{
|
||||
$this->authorizePlatformDirectoryManagement($actor);
|
||||
$reason = $this->normalizeReason($reason);
|
||||
|
||||
return DB::transaction(function () use ($workspace, $actor, $reason): Workspace {
|
||||
$workspace = Workspace::query()->lockForUpdate()->findOrFail($workspace->getKey());
|
||||
|
||||
if (! $workspace->isClosed()) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'This workspace is already open.',
|
||||
]);
|
||||
}
|
||||
|
||||
$previousClosedAt = $workspace->closed_at;
|
||||
$previousReason = $workspace->closureReason();
|
||||
$previousActorId = $workspace->closed_by_platform_user_id;
|
||||
|
||||
$workspace->forceFill([
|
||||
'closed_at' => null,
|
||||
'closed_by_platform_user_id' => null,
|
||||
'closed_reason' => null,
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceReopened,
|
||||
context: [
|
||||
'reason' => $reason,
|
||||
'before_status' => self::WORKSPACE_POSTURE_CLOSED,
|
||||
'after_status' => self::WORKSPACE_POSTURE_OPEN,
|
||||
'previous_closed_at' => $previousClosedAt?->toISOString(),
|
||||
'previous_closed_reason' => $previousReason,
|
||||
'previous_closed_by_platform_user_id' => $previousActorId,
|
||||
],
|
||||
actor: $actor,
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
targetLabel: (string) $workspace->name,
|
||||
summary: 'Workspace reopened for '.$workspace->name,
|
||||
);
|
||||
|
||||
return $workspace;
|
||||
});
|
||||
}
|
||||
|
||||
public function removeTenantFromWorkspace(ManagedEnvironment $tenant, User $actor, string $reason): ManagedEnvironment
|
||||
{
|
||||
$reason = $this->normalizeReason($reason);
|
||||
|
||||
return DB::transaction(function () use ($tenant, $actor, $reason): ManagedEnvironment {
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->with(['workspace'])
|
||||
->withTrashed()
|
||||
->lockForUpdate()
|
||||
->findOrFail($tenant->getKey());
|
||||
|
||||
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::TENANT_DELETE)) {
|
||||
throw new AuthorizationException('You are not allowed to remove this tenant from the workspace.');
|
||||
}
|
||||
|
||||
if ($tenant->workspace?->isClosed()) {
|
||||
$this->assertWorkspaceMutationAllowed($tenant->workspace);
|
||||
}
|
||||
|
||||
if ($tenant->isRemovedFromWorkspace()) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'This tenant is already removed from the workspace.',
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant->forceFill([
|
||||
'removed_from_workspace_at' => now(),
|
||||
'removed_from_workspace_by_user_id' => (int) $actor->getKey(),
|
||||
'removed_from_workspace_reason' => $reason,
|
||||
'is_current' => false,
|
||||
])->save();
|
||||
|
||||
app(WorkspaceContext::class)->clearRememberedTenantContext();
|
||||
|
||||
$this->auditLogger->logTenantLifecycleAction(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantRemovedFromWorkspace,
|
||||
context: [
|
||||
'reason' => $reason,
|
||||
'before_status' => self::TENANT_POSTURE_ACTIVE,
|
||||
'after_status' => self::TENANT_POSTURE_REMOVED,
|
||||
'removed_from_workspace_at' => $tenant->removed_from_workspace_at?->toISOString(),
|
||||
],
|
||||
actor: $actor,
|
||||
summary: 'ManagedEnvironment removed from workspace for '.$tenant->name,
|
||||
);
|
||||
|
||||
return $tenant;
|
||||
});
|
||||
}
|
||||
|
||||
public function restoreTenantToWorkspace(ManagedEnvironment $tenant, User $actor, string $reason): ManagedEnvironment
|
||||
{
|
||||
$reason = $this->normalizeReason($reason);
|
||||
|
||||
return DB::transaction(function () use ($tenant, $actor, $reason): ManagedEnvironment {
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->with(['workspace'])
|
||||
->withTrashed()
|
||||
->lockForUpdate()
|
||||
->findOrFail($tenant->getKey());
|
||||
|
||||
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::TENANT_DELETE)) {
|
||||
throw new AuthorizationException('You are not allowed to restore this tenant to the workspace.');
|
||||
}
|
||||
|
||||
if ($tenant->workspace?->isClosed()) {
|
||||
$this->assertWorkspaceMutationAllowed($tenant->workspace);
|
||||
}
|
||||
|
||||
if (! $tenant->isRemovedFromWorkspace()) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'This tenant is not removed from the workspace.',
|
||||
]);
|
||||
}
|
||||
|
||||
$previousRemovedAt = $tenant->removed_from_workspace_at;
|
||||
$previousReason = $tenant->workspaceRemovalReason();
|
||||
$previousActorId = $tenant->removed_from_workspace_by_user_id;
|
||||
|
||||
$tenant->forceFill([
|
||||
'removed_from_workspace_at' => null,
|
||||
'removed_from_workspace_by_user_id' => null,
|
||||
'removed_from_workspace_reason' => null,
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->logTenantLifecycleAction(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantRestoredToWorkspace,
|
||||
context: [
|
||||
'reason' => $reason,
|
||||
'before_status' => self::TENANT_POSTURE_REMOVED,
|
||||
'after_status' => self::TENANT_POSTURE_ACTIVE,
|
||||
'previous_removed_from_workspace_at' => $previousRemovedAt?->toISOString(),
|
||||
'previous_removed_from_workspace_reason' => $previousReason,
|
||||
'previous_removed_from_workspace_by_user_id' => $previousActorId,
|
||||
],
|
||||
actor: $actor,
|
||||
summary: 'ManagedEnvironment restored to workspace for '.$tenant->name,
|
||||
);
|
||||
|
||||
return $tenant;
|
||||
});
|
||||
}
|
||||
|
||||
private function authorizePlatformDirectoryManagement(PlatformUser $actor): void
|
||||
{
|
||||
if ($actor->hasCapability(PlatformCapabilities::DIRECTORY_MANAGE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AuthorizationException('You are not allowed to manage workspace lifecycle.');
|
||||
}
|
||||
|
||||
private function normalizeReason(string $reason): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
if (mb_strlen($reason) < 5) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'Provide a reason with at least 5 characters.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (mb_strlen($reason) > 2000) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'Provide a reason with 2000 characters or fewer.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $reason;
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,8 @@ enum AuditActionId: string
|
||||
case TenantArchived = 'tenant.archived';
|
||||
case 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',
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class TenantWorkspacePostureBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
return match (BadgeCatalog::normalizeState($value)) {
|
||||
WorkspaceLifecycleService::TENANT_POSTURE_ACTIVE => new BadgeSpec('In workspace', 'success', 'heroicon-m-check-circle'),
|
||||
WorkspaceLifecycleService::TENANT_POSTURE_REMOVED => new BadgeSpec('Removed from workspace', 'warning', 'heroicon-m-no-symbol'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Services\Workspaces\WorkspaceLifecycleService;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class WorkspaceClosurePostureBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
return match (BadgeCatalog::normalizeState($value)) {
|
||||
WorkspaceLifecycleService::WORKSPACE_POSTURE_OPEN => new BadgeSpec('Open', 'success', 'heroicon-m-check-circle'),
|
||||
WorkspaceLifecycleService::WORKSPACE_POSTURE_CLOSED => new BadgeSpec('Closed', 'danger', 'heroicon-m-lock-closed'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,7 +102,6 @@ public static function findingDatabaseNotificationMessage(Finding $finding, Mana
|
||||
actionUrl: FindingResource::getUrl(
|
||||
'view',
|
||||
['record' => $finding],
|
||||
panel: 'tenant',
|
||||
tenant: $tenant,
|
||||
),
|
||||
actionTarget: 'finding_detail',
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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.';
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('workspaces', function (Blueprint $table): void {
|
||||
$table->timestamp('closed_at')->nullable()->after('archived_at');
|
||||
$table->foreignId('closed_by_platform_user_id')->nullable()->after('closed_at')->constrained('platform_users')->nullOnDelete();
|
||||
$table->text('closed_reason')->nullable()->after('closed_by_platform_user_id');
|
||||
|
||||
$table->index('closed_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('workspaces', function (Blueprint $table): void {
|
||||
$table->dropIndex(['closed_at']);
|
||||
$table->dropConstrainedForeignId('closed_by_platform_user_id');
|
||||
$table->dropColumn(['closed_at', 'closed_reason']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('managed_environments', function (Blueprint $table): void {
|
||||
$table->timestamp('removed_from_workspace_at')->nullable()->after('deleted_at');
|
||||
$table->foreignId('removed_from_workspace_by_user_id')->nullable()->after('removed_from_workspace_at')->constrained('users')->nullOnDelete();
|
||||
$table->text('removed_from_workspace_reason')->nullable()->after('removed_from_workspace_by_user_id');
|
||||
|
||||
$table->index('removed_from_workspace_at');
|
||||
});
|
||||
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::statement('DROP INDEX IF EXISTS tenants_current_unique');
|
||||
DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON managed_environments (is_current) WHERE is_current = 1 AND deleted_at IS NULL');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('managed_environments', function (Blueprint $table): void {
|
||||
$table->dropIndex(['removed_from_workspace_at']);
|
||||
$table->dropConstrainedForeignId('removed_from_workspace_by_user_id');
|
||||
$table->dropColumn([
|
||||
'removed_from_workspace_at',
|
||||
'removed_from_workspace_reason',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -173,17 +173,25 @@
|
||||
'kpi_missing_permissions_tendency_app_only' => ':count App-Berechtigungen fehlen',
|
||||
'kpi_missing_permissions_tendency_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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user