feat: central tenant operability policy (#177)
## Summary
- centralize tenant operability into a lane-aware, actor-aware policy boundary
- align selector eligibility, administrative discoverability, remembered context, tenant-bound routes, and canonical run viewers
- add focused Pest coverage plus Spec 148 artifacts and final polish task completion
## Validation
- `vendor/bin/sail artisan test --compact tests/Unit/Tenants/TenantOperabilityServiceTest.php tests/Unit/Tenants/TenantOperabilityOutcomeTest.php tests/Feature/Workspaces/ChooseTenantPageTest.php tests/Feature/Workspaces/SelectTenantControllerTest.php tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php tests/Feature/Rbac/TenantResourceAuthorizationTest.php tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`
- manual browser smoke checks on `/admin/choose-tenant`, `/admin/tenants`, `/admin/onboarding`, `/admin/onboarding/{draft}`, and `/admin/operations/{run}`
## Filament / platform notes
- Livewire v4 compliance preserved
- panel provider registration unchanged in `bootstrap/providers.php`
- Tenant resource global search remains backed by existing view/edit pages and is now separated from active-only selector eligibility
- destructive actions remain action closures with confirmation and authorization enforcement
- no asset pipeline changes and no new `filament:assets` deployment requirement
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #177
This commit is contained in:
parent
73a879d061
commit
417df4f9aa
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -83,6 +83,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
||||||
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
|
||||||
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -102,8 +104,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 148-central-tenant-operability-policy: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface`
|
||||||
- 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
- 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
||||||
- 146-central-tenant-status-presentation: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
- 146-central-tenant-status-presentation: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
||||||
- 145-tenant-action-taxonomy-lifecycle-safe-visibility: Added PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService`
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -89,7 +91,15 @@ public function selectTenant(int $tenantId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! app(TenantOperabilityService::class)->canSelectAsContext($tenant)) {
|
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $outcome->allowed) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
@ -20,6 +21,8 @@
|
|||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -103,14 +106,7 @@ protected function getHeaderActions(): array
|
|||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
|
||||||
$tenant = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$related = OperationRunLinks::related($this->run, $tenant);
|
|
||||||
|
|
||||||
$relatedActions = [];
|
$relatedActions = [];
|
||||||
|
|
||||||
@ -372,4 +368,30 @@ private function canResumeCapture(): bool
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function relatedLinksTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: (int) ($this->run->workspace_id ?? 0),
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
)->allowed ? $tenant : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,9 @@
|
|||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Verification\VerificationAssistViewModelBuilder;
|
use App\Support\Verification\VerificationAssistViewModelBuilder;
|
||||||
use App\Support\Verification\VerificationCheckStatus;
|
use App\Support\Verification\VerificationCheckStatus;
|
||||||
use App\Support\Verification\VerificationReportOverall;
|
use App\Support\Verification\VerificationReportOverall;
|
||||||
@ -209,7 +211,13 @@ private function canViewLinkedTenant(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(TenantOperabilityService::class)->canViewTenantSurface($this->managedTenant);
|
return app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $this->managedTenant,
|
||||||
|
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: (int) $this->workspace->getKey(),
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function linkedTenantActionLabel(): string
|
private function linkedTenantActionLabel(): string
|
||||||
@ -1952,8 +1960,24 @@ private function authorizeEditableDraft(User $user): void
|
|||||||
|
|
||||||
private function canResumeDraft(?TenantOnboardingSession $draft): bool
|
private function canResumeDraft(?TenantOnboardingSession $draft): bool
|
||||||
{
|
{
|
||||||
return $draft instanceof TenantOnboardingSession
|
if (! $draft instanceof TenantOnboardingSession) {
|
||||||
&& $this->lifecycleService()->canResumeDraft($draft);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $draft->tenant instanceof Tenant) {
|
||||||
|
return $this->lifecycleService()->canResumeDraft($draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->currentUser();
|
||||||
|
|
||||||
|
return app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $draft->tenant,
|
||||||
|
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
actor: $user instanceof User ? $user : null,
|
||||||
|
workspaceId: isset($this->workspace) ? (int) $this->workspace->getKey() : null,
|
||||||
|
lane: TenantInteractionLane::OnboardingWorkflow,
|
||||||
|
onboardingDraft: $draft,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizeWorkspaceMember(User $user): void
|
private function authorizeWorkspaceMember(User $user): void
|
||||||
@ -3054,6 +3078,19 @@ private function canCompleteOnboarding(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user = $this->currentUser();
|
||||||
|
|
||||||
|
if (! app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $this->managedTenant,
|
||||||
|
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
||||||
|
actor: $user instanceof User ? $user : null,
|
||||||
|
workspaceId: isset($this->workspace) ? (int) $this->workspace->getKey() : null,
|
||||||
|
lane: TenantInteractionLane::OnboardingWorkflow,
|
||||||
|
onboardingDraft: $this->onboardingSession,
|
||||||
|
)->allowed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
|
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -3227,6 +3264,25 @@ public function completeOnboarding(): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$completionOutcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $this->managedTenant,
|
||||||
|
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: (int) $this->workspace->getKey(),
|
||||||
|
lane: TenantInteractionLane::OnboardingWorkflow,
|
||||||
|
onboardingDraft: $this->onboardingSession,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $completionOutcome->allowed) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Onboarding unavailable')
|
||||||
|
->body('This tenant can no longer be completed from the current onboarding workflow state.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$run = $this->verificationRun();
|
$run = $this->verificationRun();
|
||||||
$verificationSucceeded = $this->verificationHasSucceeded();
|
$verificationSucceeded = $this->verificationHasSucceeded();
|
||||||
$verificationCanProceed = $this->verificationCanProceed();
|
$verificationCanProceed = $this->verificationCanProceed();
|
||||||
|
|||||||
@ -10,6 +10,9 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
@ -64,7 +67,15 @@ public function getTenants(): Collection
|
|||||||
->where('workspace_id', $this->workspace->getKey())
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->filter(fn (Tenant $tenant): bool => app(TenantOperabilityService::class)->canViewTenantSurface($tenant))
|
->filter(function (Tenant $tenant) use ($user): bool {
|
||||||
|
return app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
)->allowed;
|
||||||
|
})
|
||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,10 @@
|
|||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Tenants\TenantActionDescriptor;
|
use App\Support\Tenants\TenantActionDescriptor;
|
||||||
use App\Support\Tenants\TenantActionSurface;
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
|
use App\Support\Tenants\TenantOperabilityOutcome;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -228,7 +231,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
|||||||
return static::getEloquentQuery()->whereRaw('1 = 0');
|
return static::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::tenantOperability()->applySelectableScope(
|
return static::tenantOperability()->applyAdministrativeDiscoverabilityScope(
|
||||||
static::getEloquentQuery(),
|
static::getEloquentQuery(),
|
||||||
(new Tenant)->getTable(),
|
(new Tenant)->getTable(),
|
||||||
);
|
);
|
||||||
@ -513,7 +516,7 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
StartVerification $verification,
|
StartVerification $verification,
|
||||||
@ -1081,6 +1084,26 @@ public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
|||||||
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function verificationActionVisible(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$outcome = static::verificationReadinessOutcome($tenant);
|
||||||
|
|
||||||
|
return $outcome->allowed || $outcome->isDeniedForCapability();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verificationReadinessOutcome(Tenant $tenant): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return static::tenantOperability()->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||||
|
actor: $user instanceof User ? $user : null,
|
||||||
|
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
|
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
|
||||||
{
|
{
|
||||||
$descriptor = static::tenantActionCatalog($tenant, $surface)
|
$descriptor = static::tenantActionCatalog($tenant, $surface)
|
||||||
|
|||||||
@ -87,7 +87,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
StartVerification $verification,
|
StartVerification $verification,
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -47,7 +49,15 @@ public function __invoke(Request $request): RedirectResponse
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! app(TenantOperabilityService::class)->canSelectAsContext($tenant)) {
|
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $outcome->allowed) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,10 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Auth\Access\Response;
|
use Illuminate\Auth\Access\Response;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@ -95,8 +98,18 @@ private function authorizeForDraft(
|
|||||||
|
|
||||||
$tenant = $tenantOnboardingSession->tenant;
|
$tenant = $tenantOnboardingSession->tenant;
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && ! $user->canAccessTenant($tenant)) {
|
if ($tenant instanceof Tenant) {
|
||||||
return Response::denyAsNotFound();
|
$viewability = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: (int) $workspace->getKey(),
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $viewability->allowed) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->authorizeForWorkspace($user, $workspace, $capability);
|
return $this->authorizeForWorkspace($user, $workspace, $capability);
|
||||||
|
|||||||
@ -13,6 +13,10 @@
|
|||||||
use App\Support\Tenants\TenantActionDescriptor;
|
use App\Support\Tenants\TenantActionDescriptor;
|
||||||
use App\Support\Tenants\TenantActionFamily;
|
use App\Support\Tenants\TenantActionFamily;
|
||||||
use App\Support\Tenants\TenantActionSurface;
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityOutcome;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class TenantActionPolicySurface
|
class TenantActionPolicySurface
|
||||||
@ -20,6 +24,7 @@ class TenantActionPolicySurface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantOperabilityService $tenantOperabilityService,
|
private readonly TenantOperabilityService $tenantOperabilityService,
|
||||||
private readonly OnboardingLifecycleService $onboardingLifecycleService,
|
private readonly OnboardingLifecycleService $onboardingLifecycleService,
|
||||||
|
private readonly WorkspaceContext $workspaceContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function buildContext(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): TenantActionContext
|
public function buildContext(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): TenantActionContext
|
||||||
@ -27,16 +32,34 @@ public function buildContext(Tenant $tenant, TenantActionSurface $surface, ?User
|
|||||||
$user ??= auth()->user();
|
$user ??= auth()->user();
|
||||||
$draft = $user instanceof User ? $this->relatedOnboardingDraft($tenant, $user) : null;
|
$draft = $user instanceof User ? $this->relatedOnboardingDraft($tenant, $user) : null;
|
||||||
$lifecycle = $this->tenantOperabilityService->lifecycleFor($tenant);
|
$lifecycle = $this->tenantOperabilityService->lifecycleFor($tenant);
|
||||||
|
$lane = $surface->isOnboardingSurface()
|
||||||
|
? TenantInteractionLane::OnboardingWorkflow
|
||||||
|
: TenantInteractionLane::AdministrativeManagement;
|
||||||
|
$workspaceId = request() !== null
|
||||||
|
? $this->workspaceContext->currentWorkspaceId(request())
|
||||||
|
: null;
|
||||||
|
$resumeOutcome = $draft instanceof TenantOnboardingSession
|
||||||
|
? $this->tenantOperabilityService->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
actor: $user instanceof User ? $user : null,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: $lane,
|
||||||
|
onboardingDraft: $draft,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return new TenantActionContext(
|
return new TenantActionContext(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
lifecycle: $lifecycle,
|
lifecycle: $lifecycle,
|
||||||
surface: $surface,
|
surface: $surface,
|
||||||
|
actor: $user instanceof User ? $user : null,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: $lane,
|
||||||
relatedOnboardingDraft: $draft,
|
relatedOnboardingDraft: $draft,
|
||||||
relatedOnboardingIsResumable: $draft instanceof TenantOnboardingSession
|
relatedOnboardingIsResumable: $resumeOutcome?->allowed ?? false,
|
||||||
&& $this->onboardingLifecycleService->canResumeDraft($draft),
|
|
||||||
hasRelatedOnboardingDraft: $draft instanceof TenantOnboardingSession,
|
hasRelatedOnboardingDraft: $draft instanceof TenantOnboardingSession,
|
||||||
isArchived: $tenant->trashed() || $this->tenantOperabilityService->canRestore($tenant),
|
isArchived: $lifecycle->canRestore(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,7 +208,15 @@ private function lifecycleActionForContext(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->tenantOperabilityService->canRestore($context->tenant)) {
|
$restoreOutcome = $this->tenantOperabilityService->outcomeFor(
|
||||||
|
tenant: $context->tenant,
|
||||||
|
question: TenantOperabilityQuestion::RestoreEligibility,
|
||||||
|
actor: $context->actor,
|
||||||
|
workspaceId: $context->workspaceId,
|
||||||
|
lane: $context->lane,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->shouldExposeAction($restoreOutcome)) {
|
||||||
return new TenantActionDescriptor(
|
return new TenantActionDescriptor(
|
||||||
key: 'restore',
|
key: 'restore',
|
||||||
family: TenantActionFamily::LifecycleManagement,
|
family: TenantActionFamily::LifecycleManagement,
|
||||||
@ -202,7 +233,15 @@ private function lifecycleActionForContext(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->tenantOperabilityService->canArchive($context->tenant)) {
|
$archiveOutcome = $this->tenantOperabilityService->outcomeFor(
|
||||||
|
tenant: $context->tenant,
|
||||||
|
question: TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
actor: $context->actor,
|
||||||
|
workspaceId: $context->workspaceId,
|
||||||
|
lane: $context->lane,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->shouldExposeAction($archiveOutcome)) {
|
||||||
return new TenantActionDescriptor(
|
return new TenantActionDescriptor(
|
||||||
key: 'archive',
|
key: 'archive',
|
||||||
family: TenantActionFamily::LifecycleManagement,
|
family: TenantActionFamily::LifecycleManagement,
|
||||||
@ -232,7 +271,16 @@ private function relatedOnboardingActionForContext(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($context->relatedOnboardingIsResumable && $context->lifecycle->canResumeOnboarding()) {
|
$resumeOutcome = $this->tenantOperabilityService->outcomeFor(
|
||||||
|
tenant: $context->tenant,
|
||||||
|
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
actor: $context->actor,
|
||||||
|
workspaceId: $context->workspaceId,
|
||||||
|
lane: $context->lane,
|
||||||
|
onboardingDraft: $draft,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($resumeOutcome->allowed) {
|
||||||
return new TenantActionDescriptor(
|
return new TenantActionDescriptor(
|
||||||
key: 'related_onboarding',
|
key: 'related_onboarding',
|
||||||
family: TenantActionFamily::OnboardingWorkflow,
|
family: TenantActionFamily::OnboardingWorkflow,
|
||||||
@ -270,4 +318,9 @@ private function relatedOnboardingActionForContext(
|
|||||||
group: $group,
|
group: $group,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldExposeAction(TenantOperabilityOutcome $outcome): bool
|
||||||
|
{
|
||||||
|
return $outcome->allowed || $outcome->isDeniedForCapability();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,27 +5,151 @@
|
|||||||
namespace App\Services\Tenants;
|
namespace App\Services\Tenants;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecycle;
|
use App\Support\Tenants\TenantLifecycle;
|
||||||
|
use App\Support\Tenants\TenantOperabilityContext;
|
||||||
use App\Support\Tenants\TenantOperabilityDecision;
|
use App\Support\Tenants\TenantOperabilityDecision;
|
||||||
|
use App\Support\Tenants\TenantOperabilityOutcome;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class TenantOperabilityService
|
class TenantOperabilityService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function decisionFor(Tenant $tenant): TenantOperabilityDecision
|
public function decisionFor(Tenant $tenant): TenantOperabilityDecision
|
||||||
{
|
{
|
||||||
$lifecycle = TenantLifecycle::fromTenant($tenant);
|
return TenantOperabilityDecision::fromOutcomes([
|
||||||
$isArchived = $tenant->trashed() || $lifecycle === TenantLifecycle::Archived;
|
TenantOperabilityQuestion::SelectorEligibility->value => $this->evaluate(
|
||||||
|
TenantOperabilityContext::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::AdministrativeDiscoverability->value => $this->evaluate(
|
||||||
|
TenantOperabilityContext::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::ArchiveEligibility->value => $this->evaluate(
|
||||||
|
TenantOperabilityContext::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::RestoreEligibility->value => $this->evaluate(
|
||||||
|
TenantOperabilityContext::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::RestoreEligibility,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::ResumeOnboardingEligibility->value => $this->evaluate(
|
||||||
|
TenantOperabilityContext::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::CanonicalLinkedRecordViewability->value => $this->evaluate(
|
||||||
|
TenantOperabilityContext::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
lane: TenantInteractionLane::CanonicalWorkspaceRecord,
|
||||||
|
),
|
||||||
|
TenantOperabilityQuestion::CanonicalLinkedRecordViewability,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return new TenantOperabilityDecision(
|
public function evaluate(TenantOperabilityContext $context, TenantOperabilityQuestion $question): TenantOperabilityOutcome
|
||||||
lifecycle: $lifecycle,
|
{
|
||||||
canViewTenantSurface: $lifecycle->canViewTenantSurface(),
|
$lifecycle = TenantLifecycle::fromTenant($context->tenant);
|
||||||
canSelectAsContext: ! $tenant->trashed() && $lifecycle->canSelectAsContext(),
|
|
||||||
canOperate: ! $tenant->trashed() && $lifecycle->canOperate(),
|
if ($context->workspaceId !== null && (int) $context->tenant->workspace_id !== $context->workspaceId) {
|
||||||
canArchive: ! $isArchived && $lifecycle->canArchive(),
|
return TenantOperabilityOutcome::deny(
|
||||||
canRestore: $isArchived || $lifecycle->canRestore(),
|
question: $question,
|
||||||
canResumeOnboarding: ! $tenant->trashed() && $lifecycle->canResumeOnboarding(),
|
lifecycle: $lifecycle,
|
||||||
canReferenceInWorkspaceMonitoring: $lifecycle->canReferenceInWorkspaceMonitoring(),
|
lane: $context->lane,
|
||||||
|
reasonCode: $question === TenantOperabilityQuestion::RememberedContextValidity
|
||||||
|
? TenantOperabilityReasonCode::RememberedContextStale
|
||||||
|
: TenantOperabilityReasonCode::WorkspaceMismatch,
|
||||||
|
discoverable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($context->actor instanceof User && ! $this->capabilityResolver->isMember($context->actor, $context->tenant)) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: $question,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::TenantNotEntitled,
|
||||||
|
discoverable: false,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($context->requiredCapability !== null && $context->actor instanceof User && ! $this->capabilityResolver->can($context->actor, $context->tenant, $context->requiredCapability)) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: $question,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::MissingCapability,
|
||||||
|
discoverable: $question === TenantOperabilityQuestion::AdministrativeDiscoverability || $question === TenantOperabilityQuestion::TenantBoundViewability,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($question) {
|
||||||
|
TenantOperabilityQuestion::SelectorEligibility => $this->selectorEligibilityOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::RememberedContextValidity => $this->rememberedContextOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::TenantBoundViewability => $this->tenantBoundViewabilityOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::CanonicalLinkedRecordViewability => $this->canonicalViewabilityOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::ArchiveEligibility => $this->archiveEligibilityOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::RestoreEligibility => $this->restoreEligibilityOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::ResumeOnboardingEligibility => $this->resumeOnboardingOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::OnboardingCompletionEligibility => $this->onboardingCompletionOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::VerificationReadinessEligibility => $this->verificationReadinessOutcome($context, $lifecycle),
|
||||||
|
TenantOperabilityQuestion::AdministrativeDiscoverability => $this->administrativeDiscoverabilityOutcome($context, $lifecycle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outcomeFor(
|
||||||
|
Tenant $tenant,
|
||||||
|
TenantOperabilityQuestion $question,
|
||||||
|
?User $actor = null,
|
||||||
|
?int $workspaceId = null,
|
||||||
|
TenantInteractionLane $lane = TenantInteractionLane::AdministrativeManagement,
|
||||||
|
?TenantOnboardingSession $onboardingDraft = null,
|
||||||
|
?string $requiredCapability = null,
|
||||||
|
?Tenant $selectedTenant = null,
|
||||||
|
?string $linkedRecordType = null,
|
||||||
|
?int $linkedRecordId = null,
|
||||||
|
): TenantOperabilityOutcome {
|
||||||
|
return $this->evaluate(
|
||||||
|
TenantOperabilityContext::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: $lane,
|
||||||
|
onboardingDraft: $onboardingDraft,
|
||||||
|
requiredCapability: $requiredCapability,
|
||||||
|
selectedTenant: $selectedTenant,
|
||||||
|
linkedRecordType: $linkedRecordType,
|
||||||
|
linkedRecordId: $linkedRecordId,
|
||||||
|
),
|
||||||
|
$question,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,27 +160,47 @@ public function lifecycleFor(Tenant $tenant): TenantLifecycle
|
|||||||
|
|
||||||
public function canSelectAsContext(Tenant $tenant): bool
|
public function canSelectAsContext(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $this->decisionFor($tenant)->canSelectAsContext;
|
return $this->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canViewTenantSurface(Tenant $tenant): bool
|
public function canViewTenantSurface(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $this->decisionFor($tenant)->canViewTenantSurface;
|
return $this->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canResumeOnboarding(Tenant $tenant): bool
|
public function canResumeOnboarding(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $this->decisionFor($tenant)->canResumeOnboarding;
|
return $this->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canArchive(Tenant $tenant): bool
|
public function canArchive(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $this->decisionFor($tenant)->canArchive;
|
return $this->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canRestore(Tenant $tenant): bool
|
public function canRestore(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $this->decisionFor($tenant)->canRestore;
|
return $this->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::RestoreEligibility,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function primaryManagementActionKey(Tenant $tenant, bool $preferOnboarding = false): ?string
|
public function primaryManagementActionKey(Tenant $tenant, bool $preferOnboarding = false): ?string
|
||||||
@ -66,7 +210,11 @@ public function primaryManagementActionKey(Tenant $tenant, bool $preferOnboardin
|
|||||||
|
|
||||||
public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $this->decisionFor($tenant)->canReferenceInWorkspaceMonitoring;
|
return $this->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::CanonicalLinkedRecordViewability,
|
||||||
|
lane: TenantInteractionLane::CanonicalWorkspaceRecord,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -88,4 +236,298 @@ public function applySelectableScope(Builder $query, ?string $table = null): Bui
|
|||||||
->whereNull("{$prefix}deleted_at")
|
->whereNull("{$prefix}deleted_at")
|
||||||
->where("{$prefix}status", TenantLifecycle::Active->value);
|
->where("{$prefix}status", TenantLifecycle::Active->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function applyAdministrativeDiscoverabilityScope(Builder $query, ?string $table = null): Builder
|
||||||
|
{
|
||||||
|
$prefix = $table !== null && $table !== '' ? "{$table}." : '';
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->withTrashed()
|
||||||
|
->where(function (Builder $builder) use ($prefix): void {
|
||||||
|
$builder->whereIn("{$prefix}status", TenantLifecycle::values())
|
||||||
|
->orWhereNotNull("{$prefix}deleted_at");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectorEligibilityOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if ($context->lane !== TenantInteractionLane::StandardActiveOperating) {
|
||||||
|
return $this->wrongLaneOutcome($context, TenantOperabilityQuestion::SelectorEligibility, $lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $lifecycle->isSelectableInStandardLane()) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::SelectorIneligibleLifecycle,
|
||||||
|
discoverable: false,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rememberedContextOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if ($context->lane !== TenantInteractionLane::StandardActiveOperating) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::RememberedContextValidity,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::RememberedContextStale,
|
||||||
|
discoverable: false,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $lifecycle->isSelectableInStandardLane()) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::RememberedContextValidity,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::RememberedContextStale,
|
||||||
|
discoverable: false,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::RememberedContextValidity,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantBoundViewabilityOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if ($context->lane !== TenantInteractionLane::AdministrativeManagement) {
|
||||||
|
return $this->wrongLaneOutcome($context, TenantOperabilityQuestion::TenantBoundViewability, $lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canonicalViewabilityOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if ($context->lane !== TenantInteractionLane::CanonicalWorkspaceRecord) {
|
||||||
|
return $this->wrongLaneOutcome($context, TenantOperabilityQuestion::CanonicalLinkedRecordViewability, $lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::CanonicalLinkedRecordViewability,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function archiveEligibilityOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if ($context->lane !== TenantInteractionLane::AdministrativeManagement) {
|
||||||
|
return $this->wrongLaneOutcome($context, TenantOperabilityQuestion::ArchiveEligibility, $lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $lifecycle->canArchive()) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: $lifecycle === TenantLifecycle::Archived
|
||||||
|
? TenantOperabilityReasonCode::TenantAlreadyArchived
|
||||||
|
: TenantOperabilityReasonCode::SelectorIneligibleLifecycle,
|
||||||
|
discoverable: true,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function restoreEligibilityOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if ($context->lane !== TenantInteractionLane::AdministrativeManagement) {
|
||||||
|
return $this->wrongLaneOutcome($context, TenantOperabilityQuestion::RestoreEligibility, $lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $lifecycle->canRestore()) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::RestoreEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::TenantNotArchived,
|
||||||
|
discoverable: true,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::RestoreEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resumeOnboardingOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if (! in_array($context->lane, [TenantInteractionLane::AdministrativeManagement, TenantInteractionLane::OnboardingWorkflow], true)) {
|
||||||
|
return $this->wrongLaneOutcome($context, TenantOperabilityQuestion::ResumeOnboardingEligibility, $lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $lifecycle->canResumeOnboarding()) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::SelectorIneligibleLifecycle,
|
||||||
|
discoverable: $context->lane === TenantInteractionLane::AdministrativeManagement,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($context->onboardingDraft instanceof TenantOnboardingSession && ! $context->onboardingDraft->isWorkflowResumable()) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::OnboardingNotResumable,
|
||||||
|
discoverable: $context->lane === TenantInteractionLane::AdministrativeManagement,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onboardingCompletionOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if ($context->lane !== TenantInteractionLane::OnboardingWorkflow) {
|
||||||
|
return $this->wrongLaneOutcome($context, TenantOperabilityQuestion::OnboardingCompletionEligibility, $lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($lifecycle, [TenantLifecycle::Draft, TenantLifecycle::Onboarding], true)) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::SelectorIneligibleLifecycle,
|
||||||
|
discoverable: false,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verificationReadinessOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
if (! $lifecycle->supportsQuestion(TenantOperabilityQuestion::VerificationReadinessEligibility, $context->lane)) {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: $context->lane === TenantInteractionLane::CanonicalWorkspaceRecord
|
||||||
|
? TenantOperabilityReasonCode::CanonicalViewFollowupOnly
|
||||||
|
: TenantOperabilityReasonCode::WrongLane,
|
||||||
|
discoverable: $context->lane === TenantInteractionLane::AdministrativeManagement,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: true,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function administrativeDiscoverabilityOutcome(TenantOperabilityContext $context, TenantLifecycle $lifecycle): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
return TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
discoverable: $lifecycle->isAdministrativelyDiscoverable(),
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function wrongLaneOutcome(
|
||||||
|
TenantOperabilityContext $context,
|
||||||
|
TenantOperabilityQuestion $question,
|
||||||
|
TenantLifecycle $lifecycle,
|
||||||
|
): TenantOperabilityOutcome {
|
||||||
|
return TenantOperabilityOutcome::deny(
|
||||||
|
question: $question,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $context->lane,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::WrongLane,
|
||||||
|
discoverable: false,
|
||||||
|
requiredCapability: $context->requiredCapability,
|
||||||
|
metadata: $this->metadata($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function metadata(TenantOperabilityContext $context): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'selected_tenant_id' => $context->selectedTenant?->getKey(),
|
||||||
|
'linked_record_type' => $context->linkedRecordType,
|
||||||
|
'linked_record_id' => $context->linkedRecordId,
|
||||||
|
'page_category' => $context->pageCategory?->value,
|
||||||
|
'onboarding_draft_id' => $context->onboardingDraft?->getKey(),
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,10 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Tenants\TenantPageCategory;
|
use App\Support\Tenants\TenantPageCategory;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Closure;
|
use Closure;
|
||||||
@ -135,7 +137,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
if (! $this->routeTenantIsAuthorized($tenant, $user, $workspaceId, $path)) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,4 +291,22 @@ private function hasCanonicalTenantSelection(Request $request): bool
|
|||||||
{
|
{
|
||||||
return app(OperateHubShell::class)->activeEntitledTenant($request) instanceof Tenant;
|
return app(OperateHubShell::class)->activeEntitledTenant($request) instanceof Tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function routeTenantIsAuthorized(Tenant $tenant, User $user, int $workspaceId, string $path): bool
|
||||||
|
{
|
||||||
|
$pageCategory = TenantPageCategory::fromPath($path);
|
||||||
|
$question = match ($pageCategory) {
|
||||||
|
TenantPageCategory::TenantBound => TenantOperabilityQuestion::TenantBoundViewability,
|
||||||
|
default => TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
|
};
|
||||||
|
|
||||||
|
return app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: $question,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: $pageCategory->lane(),
|
||||||
|
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
|
||||||
|
)->allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Tenants\TenantPageCategory;
|
use App\Support\Tenants\TenantPageCategory;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -109,7 +111,7 @@ private function resolveActiveTenant(?Request $request = null): ?Tenant
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->isEntitled($rememberedTenant, $request, TenantPageCategory::WorkspaceScoped)) {
|
if (! $this->isRememberedTenantValid($rememberedTenant, $request)) {
|
||||||
$this->workspaceContext->clearRememberedTenantContext($request);
|
$this->workspaceContext->clearRememberedTenantContext($request);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -128,7 +130,7 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
|
|||||||
|
|
||||||
$pageCategory ??= $this->pageCategory($request);
|
$pageCategory ??= $this->pageCategory($request);
|
||||||
|
|
||||||
if ($this->isEntitled($tenant, $request, $pageCategory)) {
|
if ($this->isContextTenantEntitled($tenant, $request, $pageCategory)) {
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +147,7 @@ private function resolveRouteTenant(?Request $request = null, ?TenantPageCategor
|
|||||||
if ($route?->hasParameter('tenant')) {
|
if ($route?->hasParameter('tenant')) {
|
||||||
$tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
|
$tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $this->isEntitled($tenant, $request, $pageCategory)) {
|
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +164,7 @@ private function resolveRouteTenant(?Request $request = null, ?TenantPageCategor
|
|||||||
|
|
||||||
$tenant = $this->resolveTenantRouteParameter($route->parameter('record'));
|
$tenant = $this->resolveTenantRouteParameter($route->parameter('record'));
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $this->isEntitled($tenant, $request, $pageCategory)) {
|
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,8 +195,60 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
|
private function isRouteTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
|
||||||
{
|
{
|
||||||
|
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
||||||
|
|
||||||
|
if ($pageCategory !== TenantPageCategory::TenantBound) {
|
||||||
|
return $this->isContextTenantEntitled($tenant, $request, $pageCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->evaluateOutcome(
|
||||||
|
tenant: $tenant,
|
||||||
|
request: $request,
|
||||||
|
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isContextTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
|
||||||
|
{
|
||||||
|
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
||||||
|
|
||||||
|
return match ($pageCategory) {
|
||||||
|
TenantPageCategory::TenantBound => $this->evaluateOutcome(
|
||||||
|
tenant: $tenant,
|
||||||
|
request: $request,
|
||||||
|
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
),
|
||||||
|
TenantPageCategory::CanonicalWorkspaceRecordViewer,
|
||||||
|
TenantPageCategory::OnboardingWorkflow,
|
||||||
|
TenantPageCategory::WorkspaceScoped => $this->evaluateOutcome(
|
||||||
|
tenant: $tenant,
|
||||||
|
request: $request,
|
||||||
|
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isRememberedTenantValid(Tenant $tenant, ?Request $request = null): bool
|
||||||
|
{
|
||||||
|
return $this->evaluateOutcome(
|
||||||
|
tenant: $tenant,
|
||||||
|
request: $request,
|
||||||
|
question: TenantOperabilityQuestion::RememberedContextValidity,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluateOutcome(
|
||||||
|
Tenant $tenant,
|
||||||
|
?Request $request,
|
||||||
|
TenantOperabilityQuestion $question,
|
||||||
|
TenantInteractionLane $lane,
|
||||||
|
): bool {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
@ -211,13 +265,14 @@ private function isEntitled(Tenant $tenant, ?Request $request = null, ?TenantPag
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$decision = $this->tenantOperabilityService->decisionFor($tenant);
|
return $this->tenantOperabilityService->outcomeFor(
|
||||||
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
tenant: $tenant,
|
||||||
|
question: $question,
|
||||||
return match ($pageCategory) {
|
actor: $user,
|
||||||
TenantPageCategory::TenantBound => $decision->canViewTenantSurface,
|
workspaceId: $workspaceId,
|
||||||
default => $decision->canSelectAsContext,
|
lane: $lane,
|
||||||
};
|
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
|
||||||
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pageCategory(?Request $request = null): TenantPageCategory
|
private function pageCategory(?Request $request = null): TenantPageCategory
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
final readonly class TenantActionContext
|
final readonly class TenantActionContext
|
||||||
{
|
{
|
||||||
@ -13,6 +14,9 @@ public function __construct(
|
|||||||
public Tenant $tenant,
|
public Tenant $tenant,
|
||||||
public TenantLifecycle $lifecycle,
|
public TenantLifecycle $lifecycle,
|
||||||
public TenantActionSurface $surface,
|
public TenantActionSurface $surface,
|
||||||
|
public ?User $actor,
|
||||||
|
public ?int $workspaceId,
|
||||||
|
public TenantInteractionLane $lane,
|
||||||
public ?TenantOnboardingSession $relatedOnboardingDraft,
|
public ?TenantOnboardingSession $relatedOnboardingDraft,
|
||||||
public bool $relatedOnboardingIsResumable,
|
public bool $relatedOnboardingIsResumable,
|
||||||
public bool $hasRelatedOnboardingDraft,
|
public bool $hasRelatedOnboardingDraft,
|
||||||
|
|||||||
23
app/Support/Tenants/TenantInteractionLane.php
Normal file
23
app/Support/Tenants/TenantInteractionLane.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
enum TenantInteractionLane: string
|
||||||
|
{
|
||||||
|
case StandardActiveOperating = 'standard_active_operating';
|
||||||
|
case OnboardingWorkflow = 'onboarding_workflow';
|
||||||
|
case AdministrativeManagement = 'administrative_management';
|
||||||
|
case CanonicalWorkspaceRecord = 'canonical_workspace_record';
|
||||||
|
|
||||||
|
public static function fromPageCategory(TenantPageCategory $pageCategory): self
|
||||||
|
{
|
||||||
|
return match ($pageCategory) {
|
||||||
|
TenantPageCategory::OnboardingWorkflow => self::OnboardingWorkflow,
|
||||||
|
TenantPageCategory::TenantBound => self::AdministrativeManagement,
|
||||||
|
TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
|
||||||
|
TenantPageCategory::WorkspaceScoped => self::StandardActiveOperating,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -81,16 +81,56 @@ public function label(): string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canSelectAsContext(): bool
|
public function isSelectableInStandardLane(): bool
|
||||||
{
|
{
|
||||||
return $this === self::Active;
|
return $this === self::Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canViewTenantSurface(): bool
|
public function isAdministrativelyDiscoverable(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isViewableInLane(TenantInteractionLane $lane): bool
|
||||||
|
{
|
||||||
|
return match ($lane) {
|
||||||
|
TenantInteractionLane::StandardActiveOperating => $this->isSelectableInStandardLane(),
|
||||||
|
TenantInteractionLane::OnboardingWorkflow => in_array($this, [self::Draft, self::Onboarding], true),
|
||||||
|
TenantInteractionLane::AdministrativeManagement, TenantInteractionLane::CanonicalWorkspaceRecord => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsQuestion(TenantOperabilityQuestion $question, TenantInteractionLane $lane): bool
|
||||||
|
{
|
||||||
|
return match ($question) {
|
||||||
|
TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
TenantOperabilityQuestion::RememberedContextValidity => $lane === TenantInteractionLane::StandardActiveOperating && $this->isSelectableInStandardLane(),
|
||||||
|
TenantOperabilityQuestion::TenantBoundViewability => $lane === TenantInteractionLane::AdministrativeManagement,
|
||||||
|
TenantOperabilityQuestion::CanonicalLinkedRecordViewability => $lane === TenantInteractionLane::CanonicalWorkspaceRecord,
|
||||||
|
TenantOperabilityQuestion::ArchiveEligibility => $lane === TenantInteractionLane::AdministrativeManagement && $this->canArchive(),
|
||||||
|
TenantOperabilityQuestion::RestoreEligibility => $lane === TenantInteractionLane::AdministrativeManagement && $this->canRestore(),
|
||||||
|
TenantOperabilityQuestion::ResumeOnboardingEligibility => in_array($lane, [TenantInteractionLane::AdministrativeManagement, TenantInteractionLane::OnboardingWorkflow], true) && $this->canResumeOnboarding(),
|
||||||
|
TenantOperabilityQuestion::OnboardingCompletionEligibility => $lane === TenantInteractionLane::OnboardingWorkflow && in_array($this, [self::Draft, self::Onboarding], true),
|
||||||
|
TenantOperabilityQuestion::VerificationReadinessEligibility => match ($lane) {
|
||||||
|
TenantInteractionLane::StandardActiveOperating => false,
|
||||||
|
TenantInteractionLane::OnboardingWorkflow => in_array($this, [self::Draft, self::Onboarding], true),
|
||||||
|
TenantInteractionLane::AdministrativeManagement => $this === self::Active,
|
||||||
|
TenantInteractionLane::CanonicalWorkspaceRecord => false,
|
||||||
|
},
|
||||||
|
TenantOperabilityQuestion::AdministrativeDiscoverability => $this->isAdministrativelyDiscoverable(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSelectAsContext(): bool
|
||||||
|
{
|
||||||
|
return $this->isSelectableInStandardLane();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canViewTenantSurface(): bool
|
||||||
|
{
|
||||||
|
return $this->isAdministrativelyDiscoverable();
|
||||||
|
}
|
||||||
|
|
||||||
public function canOperate(): bool
|
public function canOperate(): bool
|
||||||
{
|
{
|
||||||
return $this === self::Active;
|
return $this === self::Active;
|
||||||
|
|||||||
51
app/Support/Tenants/TenantOperabilityContext.php
Normal file
51
app/Support/Tenants/TenantOperabilityContext.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
final readonly class TenantOperabilityContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public Tenant $tenant,
|
||||||
|
public ?User $actor,
|
||||||
|
public ?int $workspaceId,
|
||||||
|
public TenantInteractionLane $lane,
|
||||||
|
public ?TenantPageCategory $pageCategory = null,
|
||||||
|
public ?string $linkedRecordType = null,
|
||||||
|
public ?int $linkedRecordId = null,
|
||||||
|
public ?TenantOnboardingSession $onboardingDraft = null,
|
||||||
|
public ?string $requiredCapability = null,
|
||||||
|
public ?Tenant $selectedTenant = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function forTenant(
|
||||||
|
Tenant $tenant,
|
||||||
|
?User $actor = null,
|
||||||
|
?int $workspaceId = null,
|
||||||
|
TenantInteractionLane $lane = TenantInteractionLane::AdministrativeManagement,
|
||||||
|
?TenantPageCategory $pageCategory = null,
|
||||||
|
?TenantOnboardingSession $onboardingDraft = null,
|
||||||
|
?string $requiredCapability = null,
|
||||||
|
?Tenant $selectedTenant = null,
|
||||||
|
?string $linkedRecordType = null,
|
||||||
|
?int $linkedRecordId = null,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: $lane,
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
linkedRecordType: $linkedRecordType,
|
||||||
|
linkedRecordId: $linkedRecordId,
|
||||||
|
onboardingDraft: $onboardingDraft,
|
||||||
|
requiredCapability: $requiredCapability,
|
||||||
|
selectedTenant: $selectedTenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,38 @@ public function __construct(
|
|||||||
public bool $canReferenceInWorkspaceMonitoring,
|
public bool $canReferenceInWorkspaceMonitoring,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, TenantOperabilityOutcome> $outcomes
|
||||||
|
*/
|
||||||
|
public static function fromOutcomes(array $outcomes): self
|
||||||
|
{
|
||||||
|
$selector = $outcomes[TenantOperabilityQuestion::SelectorEligibility->value] ?? null;
|
||||||
|
$discoverability = $outcomes[TenantOperabilityQuestion::AdministrativeDiscoverability->value] ?? null;
|
||||||
|
$archive = $outcomes[TenantOperabilityQuestion::ArchiveEligibility->value] ?? null;
|
||||||
|
$restore = $outcomes[TenantOperabilityQuestion::RestoreEligibility->value] ?? null;
|
||||||
|
$resumeOnboarding = $outcomes[TenantOperabilityQuestion::ResumeOnboardingEligibility->value] ?? null;
|
||||||
|
$canonical = $outcomes[TenantOperabilityQuestion::CanonicalLinkedRecordViewability->value] ?? null;
|
||||||
|
|
||||||
|
$lifecycle = $selector?->lifecycle
|
||||||
|
?? $discoverability?->lifecycle
|
||||||
|
?? $archive?->lifecycle
|
||||||
|
?? $restore?->lifecycle
|
||||||
|
?? $resumeOnboarding?->lifecycle
|
||||||
|
?? $canonical?->lifecycle
|
||||||
|
?? TenantLifecycle::Active;
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
canViewTenantSurface: $discoverability?->allowed ?? $lifecycle->canViewTenantSurface(),
|
||||||
|
canSelectAsContext: $selector?->allowed ?? $lifecycle->canSelectAsContext(),
|
||||||
|
canOperate: $lifecycle->canOperate(),
|
||||||
|
canArchive: $archive?->allowed ?? $lifecycle->canArchive(),
|
||||||
|
canRestore: $restore?->allowed ?? $lifecycle->canRestore(),
|
||||||
|
canResumeOnboarding: $resumeOnboarding?->allowed ?? $lifecycle->canResumeOnboarding(),
|
||||||
|
canReferenceInWorkspaceMonitoring: $canonical?->allowed ?? $lifecycle->canReferenceInWorkspaceMonitoring(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function allowsAction(string $actionKey): bool
|
public function allowsAction(string $actionKey): bool
|
||||||
{
|
{
|
||||||
return match ($actionKey) {
|
return match ($actionKey) {
|
||||||
|
|||||||
89
app/Support/Tenants/TenantOperabilityOutcome.php
Normal file
89
app/Support/Tenants/TenantOperabilityOutcome.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
final readonly class TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public TenantOperabilityQuestion $question,
|
||||||
|
public bool $allowed,
|
||||||
|
public TenantLifecycle $lifecycle,
|
||||||
|
public TenantInteractionLane $lane,
|
||||||
|
public ?TenantOperabilityReasonCode $reasonCode = null,
|
||||||
|
public ?string $requiredCapability = null,
|
||||||
|
public bool $discoverable = false,
|
||||||
|
public ?string $informationalMessageKey = null,
|
||||||
|
public array $metadata = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function allow(
|
||||||
|
TenantOperabilityQuestion $question,
|
||||||
|
TenantLifecycle $lifecycle,
|
||||||
|
TenantInteractionLane $lane,
|
||||||
|
bool $discoverable = true,
|
||||||
|
?string $requiredCapability = null,
|
||||||
|
?string $informationalMessageKey = null,
|
||||||
|
array $metadata = [],
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
question: $question,
|
||||||
|
allowed: true,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $lane,
|
||||||
|
reasonCode: null,
|
||||||
|
requiredCapability: $requiredCapability,
|
||||||
|
discoverable: $discoverable,
|
||||||
|
informationalMessageKey: $informationalMessageKey,
|
||||||
|
metadata: $metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function deny(
|
||||||
|
TenantOperabilityQuestion $question,
|
||||||
|
TenantLifecycle $lifecycle,
|
||||||
|
TenantInteractionLane $lane,
|
||||||
|
TenantOperabilityReasonCode $reasonCode,
|
||||||
|
bool $discoverable = false,
|
||||||
|
?string $requiredCapability = null,
|
||||||
|
?string $informationalMessageKey = null,
|
||||||
|
array $metadata = [],
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
question: $question,
|
||||||
|
allowed: false,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
lane: $lane,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
requiredCapability: $requiredCapability,
|
||||||
|
discoverable: $discoverable,
|
||||||
|
informationalMessageKey: $informationalMessageKey,
|
||||||
|
metadata: $metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeniedForCapability(): bool
|
||||||
|
{
|
||||||
|
return $this->reasonCode === TenantOperabilityReasonCode::MissingCapability;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeniedForLifecycle(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->reasonCode, [
|
||||||
|
TenantOperabilityReasonCode::SelectorIneligibleLifecycle,
|
||||||
|
TenantOperabilityReasonCode::TenantNotArchived,
|
||||||
|
TenantOperabilityReasonCode::TenantAlreadyArchived,
|
||||||
|
TenantOperabilityReasonCode::OnboardingNotResumable,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Support/Tenants/TenantOperabilityQuestion.php
Normal file
19
app/Support/Tenants/TenantOperabilityQuestion.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
enum TenantOperabilityQuestion: string
|
||||||
|
{
|
||||||
|
case SelectorEligibility = 'selector_eligibility';
|
||||||
|
case RememberedContextValidity = 'remembered_context_validity';
|
||||||
|
case TenantBoundViewability = 'tenant_bound_viewability';
|
||||||
|
case CanonicalLinkedRecordViewability = 'canonical_linked_record_viewability';
|
||||||
|
case ArchiveEligibility = 'archive_eligibility';
|
||||||
|
case RestoreEligibility = 'restore_eligibility';
|
||||||
|
case ResumeOnboardingEligibility = 'resume_onboarding_eligibility';
|
||||||
|
case OnboardingCompletionEligibility = 'onboarding_completion_eligibility';
|
||||||
|
case VerificationReadinessEligibility = 'verification_readiness_eligibility';
|
||||||
|
case AdministrativeDiscoverability = 'administrative_discoverability';
|
||||||
|
}
|
||||||
19
app/Support/Tenants/TenantOperabilityReasonCode.php
Normal file
19
app/Support/Tenants/TenantOperabilityReasonCode.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
enum TenantOperabilityReasonCode: string
|
||||||
|
{
|
||||||
|
case WorkspaceMismatch = 'workspace_mismatch';
|
||||||
|
case TenantNotEntitled = 'tenant_not_entitled';
|
||||||
|
case MissingCapability = 'missing_capability';
|
||||||
|
case WrongLane = 'wrong_lane';
|
||||||
|
case SelectorIneligibleLifecycle = 'selector_ineligible_lifecycle';
|
||||||
|
case TenantNotArchived = 'tenant_not_archived';
|
||||||
|
case TenantAlreadyArchived = 'tenant_already_archived';
|
||||||
|
case OnboardingNotResumable = 'onboarding_not_resumable';
|
||||||
|
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
|
||||||
|
case RememberedContextStale = 'remembered_context_stale';
|
||||||
|
}
|
||||||
@ -43,4 +43,9 @@ public static function fromPath(string $path): self
|
|||||||
|
|
||||||
return self::WorkspaceScoped;
|
return self::WorkspaceScoped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function lane(): TenantInteractionLane
|
||||||
|
{
|
||||||
|
return TenantInteractionLane::fromPageCategory($this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
final class WorkspaceContext
|
final class WorkspaceContext
|
||||||
@ -82,7 +84,15 @@ public function rememberTenantContext(Tenant $tenant, ?Request $request = null):
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->tenantOperabilityService->canSelectAsContext($tenant)) {
|
$outcome = $this->tenantOperabilityService->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $request?->user() instanceof User ? $request->user() : auth()->user(),
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
question: TenantOperabilityQuestion::RememberedContextValidity,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $outcome->allowed) {
|
||||||
$this->clearLastTenantId($request);
|
$this->clearLastTenantId($request);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@ -177,7 +187,15 @@ public function rememberedTenant(?Request $request = null): ?Tenant
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->tenantOperabilityService->canSelectAsContext($tenant)) {
|
$outcome = $this->tenantOperabilityService->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $request?->user() instanceof User ? $request->user() : auth()->user(),
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
question: TenantOperabilityQuestion::RememberedContextValidity,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $outcome->allowed) {
|
||||||
$this->clearRememberedTenantContext($request);
|
$this->clearRememberedTenantContext($request);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -84,6 +84,13 @@ ### Standardization & Policy Quality ("Intune Linting")
|
|||||||
Policy linter (naming, scope tag requirements, no All-Users on high-risk), company standards as templates, policy hygiene (duplicate finder, unassigned, orphaned, stale).
|
Policy linter (naming, scope tag requirements, no All-Users on high-risk), company standards as templates, policy hygiene (duplicate finder, unassigned, orphaned, stale).
|
||||||
**Source**: 0800-future-features brainstorming.
|
**Source**: 0800-future-features brainstorming.
|
||||||
|
|
||||||
|
### Compliance Readiness & Executive Review Packs
|
||||||
|
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. BSI-/NIS2-/CIS-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
|
||||||
|
**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand.
|
||||||
|
**Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation.
|
||||||
|
**Depends on**: StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
|
||||||
|
**Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Long-term
|
## Long-term
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-16 (action surface cluster added, tenant draft discard lifecycle added)
|
**Last reviewed**: 2026-03-17 (Help Center, Documentation Pipeline, Drift Notifications Settings, User Invitations added)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -393,6 +393,66 @@ ### Policy Setting Explorer — Reverse Lookup for Tenant Configuration
|
|||||||
- **Dependencies**: Inventory sync stable, policy versioning (snapshots), tenant context model, RBAC capability system (066+)
|
- **Dependencies**: Inventory sync stable, policy versioning (snapshots), tenant context model, RBAC capability system (066+)
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Help Center / Documentation Surface
|
||||||
|
- **Type**: feature
|
||||||
|
- **Source**: product planning, operator support friction analysis
|
||||||
|
- **Problem**: TenantPilot lacks a first-class in-product knowledge surface for operators. As the platform grows in governance depth, operators need contextual guidance, workflow explanations, role/capability explanations, remediation help, and product documentation without leaving the admin experience. Today, knowledge is fragmented across specs, internal docs, and implicit operator expectations.
|
||||||
|
- **Why it matters**: Reduces support friction, improves operator onboarding, enables self-service resolution, and makes advanced governance features more understandable and adoptable. Provides a canonical product-help layer distinct from audit/evidence/reporting artifacts. This is a product maturity and support-efficiency capability, not a content management system.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Markdown-based documentation stored in-repo, rendered inside the Filament admin product
|
||||||
|
- Global documentation search
|
||||||
|
- Contextual help entry points on relevant resources/pages (modal / slideover preview where appropriate)
|
||||||
|
- Clear separation between product help/knowledge and audit/report/evidence exports
|
||||||
|
- Workspace/tenant context awareness only where helpful for navigation, not to turn docs into tenant data
|
||||||
|
- **Explicit non-goals**: Not a customer support ticket system. Not an audit pack feature. Not a generic CMS. Not a replacement for external knowledge bases if those exist separately.
|
||||||
|
- **Dependencies**: Filament panel infrastructure, existing navigation/information architecture
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### Documentation Generation Pipeline and Editorial Workflow
|
||||||
|
- **Type**: feature
|
||||||
|
- **Source**: product planning, documentation sustainability analysis
|
||||||
|
- **Problem**: Even with a markdown-based knowledge layer, documentation quality and coverage will degrade without a lightweight authoring pipeline. The product needs a structured way to generate document skeletons/templates, support repeatable documentation workflows, and optionally use AI-assisted drafting without treating generated text as authoritative by default.
|
||||||
|
- **Why it matters**: Without a documentation pipeline, docs become inconsistent, coverage drifts as features grow, teams fall back to ad hoc writing, the help layer becomes expensive to maintain, and future AI-assisted documentation lacks guardrails.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Document skeleton or template generation (e.g. command/tooling such as `docs:generate`)
|
||||||
|
- Structured frontmatter / metadata expectations where useful
|
||||||
|
- Editorial states such as draft / needs review / published
|
||||||
|
- Explicit "AI draft needs review" semantics to distinguish generated drafts from canonical reviewed documentation
|
||||||
|
- Repo-native markdown workflow as the source of truth
|
||||||
|
- **Explicit non-goals**: Not a replacement for careful documentation authorship. Not a public marketing content engine. Not a promise of autonomous documentation generation. This is an internal/product documentation pipeline and editorial guardrail layer.
|
||||||
|
- **Dependencies**: Help Center / Documentation Surface (this candidate builds on the rendering/delivery surface)
|
||||||
|
- **Priority**: low
|
||||||
|
|
||||||
|
### Drift Notifications Settings Surface
|
||||||
|
- **Type**: feature
|
||||||
|
- **Source**: product planning, governance alerting direction
|
||||||
|
- **Problem**: TenantPilot has governance/alerting direction, but operators still lack a clear product surface to configure drift-related notification behavior in a predictable way. Without a dedicated settings experience, alert routing feels infrastructural rather than operator-manageable.
|
||||||
|
- **Why it matters**: Operators need tenant/workspace-level control over how governance signals reach them — email, Microsoft Teams, severity-aware routing, notification fatigue reduction, and confidence that important drift events will not be silently missed. Especially relevant for MSP-style operations and ongoing tenant reviews.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Dedicated settings-level drift notification management surface
|
||||||
|
- Delivery targets such as email and Teams
|
||||||
|
- Routing preferences by severity / event type where appropriately bounded
|
||||||
|
- Sensible defaults with cooldown / dedup / quiet-hours framing if those concepts already exist in the broader alerting direction
|
||||||
|
- Clear alignment with broader Alerts v1 direction, focused on the operator settings UX and configuration model
|
||||||
|
- **Explicit non-goals**: Not a reinvention of the whole alerts engine. Not a generic notification center for every product event. This is the operator-facing configuration surface for drift/governance notifications.
|
||||||
|
- **Dependencies**: Alerting v1 direction, drift detection foundation (Spec 044), tenant/workspace context model
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### User Invitations and Directory-based User Selection
|
||||||
|
- **Type**: feature
|
||||||
|
- **Source**: product planning, access-management UX analysis
|
||||||
|
- **Problem**: Workspace and tenant membership flows currently lack a polished enterprise-grade invitation and directory-assisted user selection experience. Operators should not need brittle manual steps to add the right person to the right workspace/tenant context.
|
||||||
|
- **Why it matters**: Improves onboarding speed, operator/admin efficiency, correctness of membership assignment, enterprise credibility of the access-management UX, and future scalability of workspace/tenant administration.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Directory-based user lookup / selection where supported
|
||||||
|
- Invitation flows initiated directly from membership management surfaces
|
||||||
|
- Invitation link / invitation lifecycle support
|
||||||
|
- Clear distinction between selecting an existing directory identity vs inviting a not-yet-active participant
|
||||||
|
- Alignment with existing RBAC / membership / workspace-first context model
|
||||||
|
- **Explicit non-goals**: Not a full identity-provider redesign. Not a replacement for the Entra auth architecture. Not a generic address-book feature. This is a bounded access-administration workflow improvement.
|
||||||
|
- **Dependencies**: RBAC/capability system (066+), workspace membership model, Entra identity integration
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
<!-- Row Interaction / Action Surface follow-up cluster (2026-03-16) -->
|
<!-- Row Interaction / Action Surface follow-up cluster (2026-03-16) -->
|
||||||
|
|
||||||
> **Action Surface follow-up direction** — The action-surface contract foundation (Specs 082, 090) and the follow-up taxonomy/viewer specs (143–146) are all fully implemented. The remaining gaps are not architectural redesign — they are incomplete adoption, missing decision criteria, and scope boundaries that haven't expanded to cover all product surfaces. The correct shape is: one foundation amendment to codify the missing rules and extend contract scope (v1.1), two compliance rollout specs to enroll currently-exempted surface families, and one targeted correction to fix the clearest remaining anti-pattern on a high-signal surface. This avoids reinventing the architecture, avoids umbrella "consistency" specs, and produces bounded, independently shippable work. TenantResource lifecycle-conditional actions and PolicyResource More-menu ordering are addressed by the updated foundation rules, not by standalone specs. Widgets, choosers, and pickers remain deferred/exempt.
|
> **Action Surface follow-up direction** — The action-surface contract foundation (Specs 082, 090) and the follow-up taxonomy/viewer specs (143–146) are all fully implemented. The remaining gaps are not architectural redesign — they are incomplete adoption, missing decision criteria, and scope boundaries that haven't expanded to cover all product surfaces. The correct shape is: one foundation amendment to codify the missing rules and extend contract scope (v1.1), two compliance rollout specs to enroll currently-exempted surface families, and one targeted correction to fix the clearest remaining anti-pattern on a high-signal surface. This avoids reinventing the architecture, avoids umbrella "consistency" specs, and produces bounded, independently shippable work. TenantResource lifecycle-conditional actions and PolicyResource More-menu ordering are addressed by the updated foundation rules, not by standalone specs. Widgets, choosers, and pickers remain deferred/exempt.
|
||||||
|
|||||||
241
docs/strategy/domain-coverage.md
Normal file
241
docs/strategy/domain-coverage.md
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
# Domain Coverage Map
|
||||||
|
|
||||||
|
> Canonical classification of Microsoft domains for TenantPilot platform planning.
|
||||||
|
> This document defines which domains receive which product primitives and why.
|
||||||
|
|
||||||
|
**Last updated**: 2026-03-17
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TenantPilot covers multiple Microsoft domains, but not all domains fit the same product model. This document establishes a stable classification so that new feature ideas, spec candidates, and roadmap entries can be evaluated against a shared understanding of how each domain maps to TenantPilot's capabilities.
|
||||||
|
|
||||||
|
The goal is not to model "all of Microsoft 365" uniformly. The goal is to build a coherent governance platform by separating domains into distinct product classes:
|
||||||
|
|
||||||
|
1. **First-class Policy Domains** — domains with explicit policy objects, settings, or rules that fit TenantPilot primitives such as inventory, versioning, diff, baseline, drift, search/explorer, findings, exceptions, and review-linked evidence.
|
||||||
|
|
||||||
|
2. **Governance / Attestation Domains** — domains centered on ownership, approvals, scheduled reviews, access attestation, lifecycle decisions, renewal/expiry workflows, and exception/risk acceptance linkage. These are not primarily configuration drift domains.
|
||||||
|
|
||||||
|
3. **Evidence / Signal Domains** — domains that primarily provide posture, risk, audit, or telemetry signals. TenantPilot ingests, historizes, correlates, reports, and alerts on these. They are not primary policy objects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic note
|
||||||
|
|
||||||
|
TenantPilot is a governance, audit, and review platform for Microsoft tenant operations. It is not a generic Microsoft 365 admin mirror and not a second RMM. It should not pretend that all Microsoft domains fit one universal primitive.
|
||||||
|
|
||||||
|
Compliance support means readiness, evidence, and governance — not formal certification claims. This domain map does not alone make customers compliant with BSI, NIS2, ISO 27001, or any other framework. It defines where TenantPilot provides deep governance coverage and where it acts as a review, evidence, or reporting layer instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. First-class Policy Domains
|
||||||
|
|
||||||
|
These are domains where TenantPilot can provide the full configuration governance stack: inventory, snapshot/versioning, compare/diff, baseline, drift detection, search/explorer, findings/exceptions, and review-linked evidence.
|
||||||
|
|
||||||
|
### Core now
|
||||||
|
|
||||||
|
**Intune Configuration**
|
||||||
|
|
||||||
|
Includes Settings Catalog, Device Configuration/Templates, Endpoint Security, Compliance Policies, and App Protection Policies.
|
||||||
|
|
||||||
|
Intune exposes large, explicit configuration surfaces with searchable and assignable settings. The Settings Catalog alone contains thousands of entries with continuous additions. Endpoint Security, Compliance, and App Protection are distinct policy families with clear assignment semantics. This is the clearest match for TenantPilot's existing backup, versioning, drift, and explorer model.
|
||||||
|
|
||||||
|
**Entra Conditional Access**
|
||||||
|
|
||||||
|
Conditional Access is Microsoft's Zero Trust policy engine. Policies are built from explicit conditions and access controls with clear target resources. This is a strong fit for inventory, review, baseline-style governance, and explainability, even though its semantics differ from device settings.
|
||||||
|
|
||||||
|
**Entra Authentication Methods**
|
||||||
|
|
||||||
|
Authentication Methods Policy is the recommended way to manage which authentication methods users or groups can use, including modern and passwordless methods. This is a strong policy domain for governance and review.
|
||||||
|
|
||||||
|
### Next
|
||||||
|
|
||||||
|
**Teams Policies**
|
||||||
|
|
||||||
|
Includes meeting policies, messaging policies, app setup policies, and other Teams policy families with assignment semantics. Teams has explicit policy families and documented assignment behavior, making it a strong secondary policy domain after Intune and Entra core security controls.
|
||||||
|
|
||||||
|
**Entra Role Definitions and Assignments**
|
||||||
|
|
||||||
|
Includes built-in roles, custom role definitions, and role assignments. Microsoft Entra supports both built-in and custom roles with a clear separation between definitions and assignments. This is central to governance and should become a first-class policy/governance surface for identity administration.
|
||||||
|
|
||||||
|
**SharePoint Tenant-level Sharing and Access Settings**
|
||||||
|
|
||||||
|
SharePoint and OneDrive external sharing are governed at both organization and site level, with the organization-level setting acting as the upper boundary. These tenant-level settings are too important for governance and audit readiness to defer beyond the next wave.
|
||||||
|
|
||||||
|
### Later
|
||||||
|
|
||||||
|
**Cross-tenant Access / External Collaboration Settings**
|
||||||
|
|
||||||
|
Cross-tenant access is a real policy domain with inbound and outbound controls for B2B collaboration and direct connect. It is more specialized and should follow the core identity and device governance surfaces.
|
||||||
|
|
||||||
|
**App Consent Policies**
|
||||||
|
|
||||||
|
Entra supports built-in and custom app consent policies that control when consent can be granted. Strategically important but narrower than the core policy domains above.
|
||||||
|
|
||||||
|
**Purview DLP Policies**
|
||||||
|
|
||||||
|
Purview DLP is a real policy domain, but significantly more complex than Intune or Conditional Access. Policies span many components, locations, conditions, and actions. It should be treated as a later premium governance domain, not an early uniform extension.
|
||||||
|
|
||||||
|
### Selective / not first-class early
|
||||||
|
|
||||||
|
**Exchange Authentication and Mail Flow Hardening**
|
||||||
|
|
||||||
|
Exchange remains relevant for governance, but it should be handled selectively rather than as an early first-class domain. Basic Authentication is already disabled across tenants, so this area is less suitable as a major new core pillar than Intune, Conditional Access, or SharePoint tenant sharing. Selective coverage of mail flow rules and remaining auth hardening checks is appropriate without committing to deep first-class platform support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Governance / Attestation Domains
|
||||||
|
|
||||||
|
These are domains where TenantPilot focuses on ownership, review schedules, approval and attestation, status and overdue visibility, exceptions/risk acceptance linkage, evidence and reporting, and alerts/renewal workflows. These domains are not primarily about configuration drift.
|
||||||
|
|
||||||
|
### Core now
|
||||||
|
|
||||||
|
**Entra Access Reviews**
|
||||||
|
|
||||||
|
Access Reviews are designed to control group membership and application access for governance, risk, and compliance purposes, including recurring reviews and policy exception review scenarios. This is a direct match for TenantPilot's governance and evidence direction.
|
||||||
|
|
||||||
|
**Privileged Identity Management (PIM)**
|
||||||
|
|
||||||
|
PIM manages, controls, and monitors privileged access, including limiting standing administrator access and reviewing privileged assignments with time- and approval-based activation. This is a first-class governance and attestation domain.
|
||||||
|
|
||||||
|
### Next
|
||||||
|
|
||||||
|
**Enterprise App / Service Principal Governance**
|
||||||
|
|
||||||
|
Includes application inventory, high-privilege permissions visibility, expiring app credentials, expiring service principal credentials, and review/renewal workflows. Microsoft Entra provides concrete recommendations for expiring application and service principal credentials, making this a strong governance surface for alerts, review, renewal, exceptions, and customer-facing evidence.
|
||||||
|
|
||||||
|
**Entitlement Management**
|
||||||
|
|
||||||
|
Includes access packages, catalogs, request workflows, approvals, reviews, and expirations. Entitlement Management automates access request workflows, assignments, reviews, and expiration at scale. It is a natural extension of TenantPilot's review and attestation layer.
|
||||||
|
|
||||||
|
### Later
|
||||||
|
|
||||||
|
**Lifecycle Workflows**
|
||||||
|
|
||||||
|
Lifecycle Workflows automate user lifecycle processes across joiner, mover, and leaver phases. Strategically relevant but fits better as a later governance automation layer than as an initial core domain.
|
||||||
|
|
||||||
|
**Terms of Use**
|
||||||
|
|
||||||
|
Terms of Use policies are relevant to access governance and user attestation, especially when enforced with Conditional Access. Narrower than Access Reviews or PIM, but suitable for inventory, status, and review pack evidence.
|
||||||
|
|
||||||
|
**Admin Consent Workflow**
|
||||||
|
|
||||||
|
Admin Consent Workflow is a governance queue and decision mechanism for applications requiring admin consent. Valuable but more focused than the broader governance domains above.
|
||||||
|
|
||||||
|
### Cross-cutting governance capability
|
||||||
|
|
||||||
|
**Guest / External Identity Lifecycle**
|
||||||
|
|
||||||
|
Guest and external identity lifecycle should be treated as a cross-cutting capability, not necessarily as a completely separate top-level domain. It spans Access Reviews (reviewing guest group memberships), Entitlement Management (access packages with reviews and expiration for external users), Cross-tenant Access (inbound/outbound collaboration controls), and later Lifecycle Workflows (leaver automation for external identities). Microsoft's governance model for external access is distributed across these features rather than isolated into one single product object.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Evidence / Signal Domains
|
||||||
|
|
||||||
|
These are domains where TenantPilot primarily ingests, normalizes lightly, historizes, correlates, reports, and alerts. They are essential for posture, audit, and review packs, but they are not the primary configuration or attestation objects.
|
||||||
|
|
||||||
|
### Core now
|
||||||
|
|
||||||
|
**Microsoft Secure Score**
|
||||||
|
|
||||||
|
Secure Score is a numerical security posture summary based on configurations, user behavior, and other security-related measurements. Ideal for history, trends, dashboards, and review reporting.
|
||||||
|
|
||||||
|
**Entra Sign-in and Audit Logs**
|
||||||
|
|
||||||
|
Sign-in logs and audit logs provide activity records used for troubleshooting, compliance, and understanding changes across users, groups, and applications. They are foundational evidence sources.
|
||||||
|
|
||||||
|
### Next
|
||||||
|
|
||||||
|
**Entra ID Protection**
|
||||||
|
|
||||||
|
ID Protection detects, investigates, and helps remediate identity-based risks. Those risk signals feed tools such as Conditional Access. Highly valuable as a risk and evidence signal domain.
|
||||||
|
|
||||||
|
**Defender Vulnerability Management**
|
||||||
|
|
||||||
|
Defender Vulnerability Management provides an exposure score reflecting how vulnerable the organization is to cyber threats. A strong evidence domain for device risk, remediation progress, and customer review packs.
|
||||||
|
|
||||||
|
**Backup Signals**
|
||||||
|
|
||||||
|
Backup belongs in the evidence layer as operational assurance, not as a normal policy domain. Microsoft 365 Backup is a distinct capability with backup policies and restore-oriented architecture, which supports ingesting success/failure and protection state as review evidence.
|
||||||
|
|
||||||
|
### Later
|
||||||
|
|
||||||
|
**Purview Unified Audit**
|
||||||
|
|
||||||
|
Purview Audit provides integrated auditing across many Microsoft services. Valuable for investigations, compliance obligations, and long-term evidence. Strategically strong but can follow the earlier evidence foundations.
|
||||||
|
|
||||||
|
**Defender for Cloud Apps**
|
||||||
|
|
||||||
|
Defender for Cloud Apps is a broad SaaS protection and visibility platform with CASB, SSPM, threat protection, and app-to-app protection capabilities. Relevant but broader than the initial evidence stack TenantPilot needs first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage Priorities
|
||||||
|
|
||||||
|
### Core now
|
||||||
|
|
||||||
|
- Intune Configuration (Settings Catalog, Device Configuration/Templates, Endpoint Security, Compliance, App Protection)
|
||||||
|
- Entra Conditional Access
|
||||||
|
- Entra Authentication Methods
|
||||||
|
- Entra Access Reviews
|
||||||
|
- Privileged Identity Management (PIM)
|
||||||
|
- Microsoft Secure Score
|
||||||
|
- Entra sign-in and audit logs
|
||||||
|
|
||||||
|
### Next
|
||||||
|
|
||||||
|
- Teams Policies
|
||||||
|
- Entra role definitions and assignments
|
||||||
|
- SharePoint tenant-level sharing and access settings
|
||||||
|
- Enterprise App / Service Principal Governance
|
||||||
|
- Entitlement Management
|
||||||
|
- Entra ID Protection
|
||||||
|
- Defender Vulnerability Management
|
||||||
|
- Backup signals
|
||||||
|
|
||||||
|
### Later
|
||||||
|
|
||||||
|
- Cross-tenant access / external collaboration settings
|
||||||
|
- App consent policies
|
||||||
|
- Purview DLP policies
|
||||||
|
- Lifecycle Workflows
|
||||||
|
- Terms of Use
|
||||||
|
- Admin Consent Workflow
|
||||||
|
- Purview unified audit
|
||||||
|
- Defender for Cloud Apps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Interpretation Rules
|
||||||
|
|
||||||
|
**Rule 1 — First-class policy domain criteria.**
|
||||||
|
A domain becomes a first-class policy domain only if it has stable policy objects, meaningful scope/assignment semantics, and enough customer value to justify inventory, versioning, compare, baseline, drift, and explorer UX.
|
||||||
|
|
||||||
|
**Rule 2 — Governance / attestation domain criteria.**
|
||||||
|
A domain becomes a governance/attestation domain when the core operator question is about review, ownership, approval, access lifecycle, expiry, or exceptions rather than about configuration compare.
|
||||||
|
|
||||||
|
**Rule 3 — Evidence / signal domain criteria.**
|
||||||
|
A domain becomes an evidence/signal domain when TenantPilot's main value is ingesting and explaining posture, risk, or audit data rather than owning the configuration object itself.
|
||||||
|
|
||||||
|
**Rule 4 — No universal primitive assumption.**
|
||||||
|
TenantPilot should avoid pretending that all Microsoft domains fit the same primitive. The platform should stay explicit about whether a feature surface treats a domain as a policy object, a review/attestation workflow, or an evidence/signal feed. That separation is what keeps the product extensible instead of turning it into a generic M365 admin mirror.
|
||||||
|
|
||||||
|
**Rule 5 — Classification before planning.**
|
||||||
|
When a new Microsoft domain is proposed for TenantPilot, it must be classified into one of the three domain classes before it is added to the roadmap or to spec-candidates. The first question is always: "Which domain class does it belong to?" Only after that should the team decide which product primitives apply, how deep coverage should go, and whether it belongs in core, next, or later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Practical Implication for Roadmap Planning
|
||||||
|
|
||||||
|
When evaluating a new feature idea or domain expansion:
|
||||||
|
|
||||||
|
1. **Classify the domain.** Determine whether it is a policy domain, a governance/attestation domain, or an evidence/signal domain. If it does not fit cleanly, it may span multiple classes — document which primitives apply to which aspect.
|
||||||
|
|
||||||
|
2. **Select the right primitives.** Policy domains get the full configuration governance stack. Governance domains get ownership, review, attestation, and exception workflows. Evidence domains get ingestion, historization, correlation, and reporting. Do not force a domain into primitives that do not fit its structure.
|
||||||
|
|
||||||
|
3. **Prioritize by customer value and extractability.** Core-now domains are those where TenantPilot already has strong foundations or where customer demand is immediate. Next domains are those with clear product fit but lower urgency or higher integration effort. Later domains are those that are strategically relevant but can wait for the platform to mature.
|
||||||
|
|
||||||
|
4. **Keep provider boundaries explicit.** Each domain's integration logic should remain domain-specific. Do not build universal abstractions that pretend all Microsoft domains share the same schema or the same semantic model.
|
||||||
|
|
||||||
|
5. **Reference this document.** Roadmap entries, spec candidates, and feature proposals should reference the domain classification established here. If a domain's classification needs to change, update this document first.
|
||||||
@ -195,7 +195,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
|
|||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
||||||
Clear selected tenant
|
Clear tenant scope
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -12,8 +12,12 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Tenants\TenantPageCategory;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use App\Support\Workspaces\WorkspaceResolver;
|
use App\Support\Workspaces\WorkspaceResolver;
|
||||||
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
||||||
@ -97,6 +101,33 @@
|
|||||||
return app(OnboardingDraftResolver::class)->resolve((int) $value, $user, $workspace);
|
return app(OnboardingDraftResolver::class)->resolve((int) $value, $user, $workspace);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$authorizeManagedTenantRoute = function (Tenant $tenant, Request $request): void {
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
abort_unless(is_int($workspaceId), 404);
|
||||||
|
abort_unless((int) $tenant->workspace_id === $workspaceId, 404);
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
abort_unless($workspace instanceof Workspace, 404);
|
||||||
|
abort_unless($workspaceContext->isMember($user, $workspace), 404);
|
||||||
|
|
||||||
|
$allowed = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: TenantPageCategory::TenantBound->lane(),
|
||||||
|
)->allowed;
|
||||||
|
|
||||||
|
abort_unless($allowed, 404);
|
||||||
|
};
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-member'])
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-member'])
|
||||||
->prefix('/admin/w/{workspace}')
|
->prefix('/admin/w/{workspace}')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
@ -148,19 +179,24 @@
|
|||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
FilamentAuthenticate::class,
|
FilamentAuthenticate::class,
|
||||||
'ensure-workspace-selected',
|
'ensure-workspace-selected',
|
||||||
'ensure-filament-tenant-selected',
|
|
||||||
])
|
])
|
||||||
->prefix('/admin/tenants/{tenant:external_id}/provider-connections')
|
->prefix('/admin/tenants/{tenant:external_id}/provider-connections')
|
||||||
->group(function (): void {
|
->group(function () use ($authorizeManagedTenantRoute): void {
|
||||||
Route::get('/', function (Tenant $tenant) {
|
Route::get('/', function (Tenant $tenant, Request $request) use ($authorizeManagedTenantRoute) {
|
||||||
|
$authorizeManagedTenantRoute($tenant, $request);
|
||||||
|
|
||||||
return redirect()->to('/admin/provider-connections?tenant_id='.$tenant->external_id);
|
return redirect()->to('/admin/provider-connections?tenant_id='.$tenant->external_id);
|
||||||
})->name('admin.provider-connections.legacy-index');
|
})->name('admin.provider-connections.legacy-index');
|
||||||
|
|
||||||
Route::get('/create', function (Tenant $tenant) {
|
Route::get('/create', function (Tenant $tenant, Request $request) use ($authorizeManagedTenantRoute) {
|
||||||
|
$authorizeManagedTenantRoute($tenant, $request);
|
||||||
|
|
||||||
return redirect()->to('/admin/provider-connections/create?tenant_id='.$tenant->external_id);
|
return redirect()->to('/admin/provider-connections/create?tenant_id='.$tenant->external_id);
|
||||||
})->name('admin.provider-connections.legacy-create');
|
})->name('admin.provider-connections.legacy-create');
|
||||||
|
|
||||||
Route::get('/{record}/edit', function (Tenant $tenant, mixed $record) {
|
Route::get('/{record}/edit', function (Tenant $tenant, mixed $record, Request $request) use ($authorizeManagedTenantRoute) {
|
||||||
|
$authorizeManagedTenantRoute($tenant, $request);
|
||||||
|
|
||||||
$connection = ProviderConnection::query()
|
$connection = ProviderConnection::query()
|
||||||
->whereKey((int) $record)
|
->whereKey((int) $record)
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Central Tenant Operability Policy
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-17
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation completed after first draft review.
|
||||||
|
- No clarification questions were required because lifecycle expectations, affected routes, ownership boundaries, and policy outcomes were explicit in the feature brief.
|
||||||
|
- Spec is ready for `/speckit.plan`.
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Tenant Operability Policy Internal Admin Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Internal planning contract for centralized tenant operability resolution
|
||||||
|
description: |
|
||||||
|
This is an internal design artifact for Spec 148. It documents the intended
|
||||||
|
route and support-layer semantics for selector eligibility, remembered-context
|
||||||
|
validation, tenant-bound viewability, canonical linked-record viewability,
|
||||||
|
and lifecycle-safe action eligibility. It does not require a new public HTTP API.
|
||||||
|
servers:
|
||||||
|
- url: /internal/admin
|
||||||
|
tags:
|
||||||
|
- name: Operability Policy
|
||||||
|
- name: Context Management
|
||||||
|
- name: Route Semantics
|
||||||
|
paths:
|
||||||
|
/tenants/{tenant}/operability:
|
||||||
|
get:
|
||||||
|
tags: [Operability Policy]
|
||||||
|
summary: Resolve one tenant-semantic decision for a specific lane and actor context
|
||||||
|
operationId: resolveTenantOperability
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- name: lane
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantInteractionLane'
|
||||||
|
- name: question
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantOperabilityQuestion'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Operability decision resolved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantOperabilityOutcome'
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks the required capability for the requested action semantics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside workspace or tenant entitlement scope
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/admin/select-tenant:
|
||||||
|
post:
|
||||||
|
tags: [Context Management]
|
||||||
|
summary: Persist remembered tenant context only when selector-lane operability allows it
|
||||||
|
operationId: selectTenantContext
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- tenant_id
|
||||||
|
properties:
|
||||||
|
tenant_id:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'302':
|
||||||
|
description: Remembered tenant context accepted and redirect issued to the tenant-lane destination
|
||||||
|
'404':
|
||||||
|
description: Tenant missing, outside workspace, not entitled, or not eligible for standard active selection
|
||||||
|
/admin/clear-tenant-context:
|
||||||
|
post:
|
||||||
|
tags: [Context Management]
|
||||||
|
summary: Clear remembered tenant context for the current workspace
|
||||||
|
operationId: clearTenantContext
|
||||||
|
responses:
|
||||||
|
'302':
|
||||||
|
description: Remembered tenant context cleared and workspace-safe redirect issued
|
||||||
|
/admin/tenants/{tenant}:
|
||||||
|
get:
|
||||||
|
tags: [Route Semantics]
|
||||||
|
summary: Resolve tenant-bound route legitimacy from route tenant plus operability and authorization
|
||||||
|
operationId: viewTenantBoundPage
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantRouteKey'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant-bound page rendered for an authorized actor
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks a required capability for the page or follow-up action
|
||||||
|
'404':
|
||||||
|
description: Tenant missing or actor not entitled to tenant scope
|
||||||
|
/admin/operations/{run}:
|
||||||
|
get:
|
||||||
|
tags: [Route Semantics]
|
||||||
|
summary: Resolve canonical workspace record viewer without requiring selected tenant equality
|
||||||
|
operationId: viewCanonicalOperationRun
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Authorized canonical run viewer rendered
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks capability for the run type or follow-up action
|
||||||
|
'404':
|
||||||
|
description: Run missing, workspace missing, or tenant entitlement missing for the referenced tenant
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
TenantId:
|
||||||
|
name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: Canonical tenant primary key used for internal policy resolution.
|
||||||
|
TenantRouteKey:
|
||||||
|
name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: External tenant route key used by tenant-bound admin routes.
|
||||||
|
schemas:
|
||||||
|
TenantInteractionLane:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- standard_active_operating
|
||||||
|
- onboarding_workflow
|
||||||
|
- administrative_management
|
||||||
|
- canonical_workspace_record
|
||||||
|
TenantOperabilityQuestion:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- selector_eligibility
|
||||||
|
- remembered_context_validity
|
||||||
|
- tenant_bound_viewability
|
||||||
|
- canonical_linked_record_viewability
|
||||||
|
- archive_eligibility
|
||||||
|
- restore_eligibility
|
||||||
|
- resume_onboarding_eligibility
|
||||||
|
- onboarding_completion_eligibility
|
||||||
|
- verification_readiness_eligibility
|
||||||
|
- administrative_discoverability
|
||||||
|
TenantOperabilityReasonCode:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
enum:
|
||||||
|
- workspace_mismatch
|
||||||
|
- tenant_not_entitled
|
||||||
|
- missing_capability
|
||||||
|
- wrong_lane
|
||||||
|
- selector_ineligible_lifecycle
|
||||||
|
- tenant_not_archived
|
||||||
|
- tenant_already_archived
|
||||||
|
- onboarding_not_resumable
|
||||||
|
- remembered_context_stale
|
||||||
|
- canonical_view_followup_only
|
||||||
|
- null
|
||||||
|
TenantOperabilityOutcome:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- tenantId
|
||||||
|
- lifecycle
|
||||||
|
- lane
|
||||||
|
- question
|
||||||
|
- allowed
|
||||||
|
- discoverable
|
||||||
|
properties:
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
lifecycle:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- draft
|
||||||
|
- onboarding
|
||||||
|
- active
|
||||||
|
- archived
|
||||||
|
lane:
|
||||||
|
$ref: '#/components/schemas/TenantInteractionLane'
|
||||||
|
question:
|
||||||
|
$ref: '#/components/schemas/TenantOperabilityQuestion'
|
||||||
|
allowed:
|
||||||
|
type: boolean
|
||||||
|
discoverable:
|
||||||
|
type: boolean
|
||||||
|
requiredCapability:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
reasonCode:
|
||||||
|
$ref: '#/components/schemas/TenantOperabilityReasonCode'
|
||||||
|
informationalMessageKey:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
RememberedTenantContextState:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- workspaceId
|
||||||
|
- status
|
||||||
|
properties:
|
||||||
|
workspaceId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- no_selected_tenant
|
||||||
|
- remembered_active
|
||||||
|
- route_authoritative_tenant
|
||||||
|
- stale_context_cleared
|
||||||
|
invalidationReason:
|
||||||
|
$ref: '#/components/schemas/TenantOperabilityReasonCode'
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
196
specs/148-central-tenant-operability-policy/data-model.md
Normal file
196
specs/148-central-tenant-operability-policy/data-model.md
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
# Phase 1 Data Model: Central Tenant Operability Policy
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not require a new database table in its first implementation slice. The primary data-model work is the formalization of existing persistent records plus new derived domain objects that express tenant operability consistently across lanes.
|
||||||
|
|
||||||
|
## Persistent Domain Entities
|
||||||
|
|
||||||
|
### Tenant
|
||||||
|
|
||||||
|
**Purpose**: Durable workspace-owned record whose lifecycle influences operability.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `external_id`
|
||||||
|
- `status` with canonical values `draft`, `onboarding`, `active`, `archived`
|
||||||
|
- `deleted_at` for archive persistence behavior
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- Belongs to one workspace
|
||||||
|
- Has many onboarding sessions, audit logs, provider connections, and tenant-owned operational records
|
||||||
|
- May be referenced by canonical workspace records such as `OperationRun`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `workspace_id` must match the active workspace scope for all in-scope route or action decisions
|
||||||
|
- `status` must resolve through the canonical `TenantLifecycle` model
|
||||||
|
- Soft-delete state is an implementation input, never the only semantic rule
|
||||||
|
|
||||||
|
**State transitions relevant to this feature**:
|
||||||
|
- `draft` → `onboarding`
|
||||||
|
- `onboarding` → `active`
|
||||||
|
- `active` → `archived`
|
||||||
|
- `archived` → `active`
|
||||||
|
|
||||||
|
### TenantOnboardingSession
|
||||||
|
|
||||||
|
**Purpose**: Separate workspace-scoped workflow record used to evaluate onboarding-lane actions.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id` nullable until linked
|
||||||
|
- workflow state and lifecycle fields already used by onboarding services
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- Belongs to one workspace
|
||||||
|
- Optionally belongs to one tenant
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Must be in the same workspace as any linked tenant
|
||||||
|
- Onboarding-specific operability decisions must validate both tenant lifecycle and workflow resumability
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
|
||||||
|
**Purpose**: Canonical workspace-owned record that may reference a tenant without becoming subordinate to selected tenant context.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id` nullable
|
||||||
|
- `type`
|
||||||
|
- `status`
|
||||||
|
- `outcome`
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- Belongs to one workspace
|
||||||
|
- May belong to one tenant reference
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Canonical route legitimacy is based on the run and workspace first
|
||||||
|
- Tenant-linked follow-up actions must still respect tenant entitlement and operability outcomes
|
||||||
|
|
||||||
|
### UserTenantPreference and WorkspaceContext Session State
|
||||||
|
|
||||||
|
**Purpose**: Stores remembered tenant context for the active operating lane.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `user_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `last_used_at`
|
||||||
|
- session-scoped workspace and remembered-tenant identifiers
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Remembered tenant is valid only when workspace match, tenant existence, entitlement, and selector-lane operability all still hold
|
||||||
|
|
||||||
|
## New Derived Domain Objects
|
||||||
|
|
||||||
|
### TenantInteractionLane
|
||||||
|
|
||||||
|
**Purpose**: Explicitly identifies the lane in which the tenant is being evaluated.
|
||||||
|
|
||||||
|
**Canonical values**:
|
||||||
|
- `standard_active_operating`
|
||||||
|
- `onboarding_workflow`
|
||||||
|
- `administrative_management`
|
||||||
|
- `canonical_workspace_record`
|
||||||
|
|
||||||
|
**Why it exists**:
|
||||||
|
- The same tenant can be selector-ineligible yet still administratively viewable or canonically referenceable
|
||||||
|
|
||||||
|
### TenantOperabilityContext
|
||||||
|
|
||||||
|
**Purpose**: Normalized evaluation input for the central policy layer.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `tenant`
|
||||||
|
- `actor`
|
||||||
|
- `workspaceId`
|
||||||
|
- `lane`
|
||||||
|
- `pageCategory`
|
||||||
|
- `linkedRecordType` nullable
|
||||||
|
- `linkedRecordId` nullable
|
||||||
|
- `onboardingDraft` nullable
|
||||||
|
- `requiredCapability` nullable
|
||||||
|
- `selectedTenant` nullable
|
||||||
|
|
||||||
|
**Context ownership**:
|
||||||
|
- Consumers normalize and pass route, record, workflow, and selected-tenant inputs into the context.
|
||||||
|
- The central service resolves workspace membership, tenant entitlement, capability truth, lifecycle, and archived persistence state through canonical workspace, tenant, and RBAC helpers at evaluation time.
|
||||||
|
- Consumers may request a capability-aware question, but they do not authoritatively decide membership or entitlement before evaluation.
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `tenant.workspace_id` must equal `workspaceId`
|
||||||
|
- linked records must belong to the same workspace when tenant-linked
|
||||||
|
- `selectedTenant` may be informative but never authoritative
|
||||||
|
|
||||||
|
### TenantOperabilityQuestion
|
||||||
|
|
||||||
|
**Purpose**: Declares the exact semantic question the consumer is asking.
|
||||||
|
|
||||||
|
**Canonical values**:
|
||||||
|
- `selector_eligibility`
|
||||||
|
- `remembered_context_validity`
|
||||||
|
- `tenant_bound_viewability`
|
||||||
|
- `canonical_linked_record_viewability`
|
||||||
|
- `archive_eligibility`
|
||||||
|
- `restore_eligibility`
|
||||||
|
- `resume_onboarding_eligibility`
|
||||||
|
- `onboarding_completion_eligibility`
|
||||||
|
- `verification_readiness_eligibility`
|
||||||
|
- `administrative_discoverability`
|
||||||
|
|
||||||
|
### TenantOperabilityOutcome
|
||||||
|
|
||||||
|
**Purpose**: Structured result returned by the central operability policy.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `question`
|
||||||
|
- `allowed`
|
||||||
|
- `lifecycle`
|
||||||
|
- `lane`
|
||||||
|
- `reasonCode` nullable
|
||||||
|
- `requiredCapability` nullable
|
||||||
|
- `discoverable` boolean
|
||||||
|
- `informationalMessageKey` nullable
|
||||||
|
- `metadata` key-value bag for consumer-safe hints
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
- Must be stable enough for unit assertions and UI mapping
|
||||||
|
- May expose helper methods for common adapters, but raw outcome data remains canonical
|
||||||
|
|
||||||
|
### TenantOperabilityReasonCode
|
||||||
|
|
||||||
|
**Purpose**: Stable denial or ineligibility reason catalog.
|
||||||
|
|
||||||
|
**Initial values**:
|
||||||
|
- `workspace_mismatch`
|
||||||
|
- `tenant_not_entitled`
|
||||||
|
- `missing_capability`
|
||||||
|
- `wrong_lane`
|
||||||
|
- `selector_ineligible_lifecycle`
|
||||||
|
- `tenant_not_archived`
|
||||||
|
- `tenant_already_archived`
|
||||||
|
- `onboarding_not_resumable`
|
||||||
|
- `canonical_view_followup_only`
|
||||||
|
- `remembered_context_stale`
|
||||||
|
|
||||||
|
## Consumer Mapping
|
||||||
|
|
||||||
|
| Consumer | Primary question(s) |
|
||||||
|
|---|---|
|
||||||
|
| Choose-tenant page | `selector_eligibility` |
|
||||||
|
| Select-tenant controller | `selector_eligibility`, `remembered_context_validity` |
|
||||||
|
| WorkspaceContext | `remembered_context_validity` |
|
||||||
|
| OperateHubShell | `tenant_bound_viewability`, `canonical_linked_record_viewability`, `administrative_discoverability` |
|
||||||
|
| TenantActionPolicySurface | `archive_eligibility`, `restore_eligibility`, `resume_onboarding_eligibility`, `verification_readiness_eligibility` |
|
||||||
|
| ManagedTenantOnboardingWizard | `resume_onboarding_eligibility`, `onboarding_completion_eligibility`, `verification_readiness_eligibility` |
|
||||||
|
| TenantResource global search and admin listing | `administrative_discoverability` |
|
||||||
|
| TenantlessOperationRunViewer | `canonical_linked_record_viewability` and follow-up action questions |
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- No persistence migration is required for the first slice.
|
||||||
|
- Existing boolean accessors on `TenantOperabilityService` may remain as compatibility adapters while consumers migrate to structured outcomes.
|
||||||
|
- Any new enums or value objects should live under `app/Support/Tenants` to stay aligned with existing lifecycle, page-category, and action-surface support code.
|
||||||
202
specs/148-central-tenant-operability-policy/plan.md
Normal file
202
specs/148-central-tenant-operability-policy/plan.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# Implementation Plan: Central Tenant Operability Policy
|
||||||
|
|
||||||
|
**Branch**: `148-central-tenant-operability-policy` | **Date**: 2026-03-17 | **Spec**: [specs/148-central-tenant-operability-policy/spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/148-central-tenant-operability-policy/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Consolidate existing tenant lifecycle and selector semantics into one actor-aware, lane-aware tenant operability authority that returns reusable structured decisions instead of scattered booleans. The implementation will evolve the existing `TenantOperabilityService` and related tenant-support types into a central policy boundary consumed by selector membership, remembered-context revalidation, tenant action catalogs, tenant-bound route checks, onboarding workflow surfaces, and canonical workspace record viewers.
|
||||||
|
|
||||||
|
This is a support-layer hardening feature, not a persistence or UI-redesign feature. The plan therefore focuses on extending the current support seams already present in `app/Services/Tenants`, `app/Support/Tenants`, `app/Support/OperateHub`, and `app/Support/Workspaces`, then migrating the highest-risk consumers first: choose-tenant and context persistence, `OperateHubShell`, `TenantActionPolicySurface`, `TenantResource`, onboarding workflow routes, and `TenantlessOperationRunViewer`.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface`
|
||||||
|
**Storage**: PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice
|
||||||
|
**Testing**: Pest 4 feature, unit, and Filament or Livewire-focused tests run through Laravel Sail
|
||||||
|
**Target Platform**: Laravel Sail web application serving the Filament admin panel and workspace-canonical `/admin` routes
|
||||||
|
**Project Type**: Laravel monolith web application
|
||||||
|
**Performance Goals**: Keep operability evaluation synchronous, deterministic, and DB or session-backed only; add no render-time external calls; avoid material query-count regressions on the chooser, tenant detail, and canonical operations viewer
|
||||||
|
**Constraints**: Preserve Spec 143 lifecycle semantics, Spec 144 canonical-viewer authority, Spec 145 action taxonomy, Spec 146 badge centralization, and Spec 147 selector semantics; keep Livewire v4 and Filament v5 compliance; leave panel-provider registration unchanged in `bootstrap/providers.php`; keep `TenantResource` globally searchable only through its existing View or Edit pages; no new asset pipeline or `filament:assets` change; destructive actions remain `->action(...)->requiresConfirmation()`; 404 versus 403 semantics must stay explicit and server-side
|
||||||
|
**Scale/Scope**: One central support-layer policy boundary plus adoption across the workspace shell, choose-tenant flow, remembered-context handling, tenant management resource, onboarding workflow surfaces, canonical operation viewer, and focused regression suites under `tests/Feature` and `tests/Unit`
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
**Pre-Phase 0 Gate: PASS**
|
||||||
|
|
||||||
|
- Inventory-first: PASS. This feature does not change inventory, snapshot, or backup ownership and only hardens policy evaluation over existing records.
|
||||||
|
- Read/write separation: PASS. No new remote writes, queued workflows, or lifecycle mutation mechanics are introduced. Existing archive and restore actions remain confirmed and audited where already implemented.
|
||||||
|
- Graph contract path: PASS. No Microsoft Graph calls are added or changed.
|
||||||
|
- Deterministic capabilities: PASS. Capability checks remain routed through the existing canonical capability registry and resolver services.
|
||||||
|
- RBAC-UX planes: PASS. This feature remains in the admin `/admin` plane. Cross-plane behavior is unchanged. Non-members stay 404, in-scope capability denial stays 403.
|
||||||
|
- Workspace isolation: PASS. Workspace remains the primary operating boundary and remembered tenant context remains workspace-scoped convenience state only.
|
||||||
|
- Tenant isolation: PASS. Tenant-linked routes and canonical viewers must still enforce workspace membership plus tenant entitlement where applicable.
|
||||||
|
- Destructive confirmation: PASS. No new destructive actions are added. Existing `Archive` and `Restore` remain `->action(...)->requiresConfirmation()` and capability-gated.
|
||||||
|
- Global search safety: PASS WITH REQUIRED ADOPTION. `TenantResource` is already globally searchable and has View and Edit pages, satisfying Filament v5 global search requirements. The implementation must ensure selector eligibility is not incorrectly reused as the only discoverability rule.
|
||||||
|
- Run observability and Ops-UX: PASS. No new `OperationRun` creation, lifecycle transition, notification, or summary-count behavior is introduced.
|
||||||
|
- Badge semantics (BADGE-001): PASS. Lifecycle and status presentation stay centralized through existing badge and lifecycle presentation helpers.
|
||||||
|
- UI naming (UI-NAMING-001): PASS. Operability explanations will reuse existing domain terms such as `Archive`, `Restore`, `Resume onboarding`, `selected tenant`, `route tenant`, and `run tenant`.
|
||||||
|
- Filament Action Surface Contract: PASS. In-scope Filament surfaces already exist; this feature changes their decision authority, not their required action inventory. Any touched lifecycle mutation remains confirmation-required and audit-aware.
|
||||||
|
- Filament UX-001: PASS. No layout redesign is planned. Existing pages keep their current layout contracts while consuming central policy outcomes.
|
||||||
|
- Asset strategy: PASS. No new Filament or front-end assets are planned, so no deployment change to `php artisan filament:assets` is required.
|
||||||
|
|
||||||
|
**Post-Phase 1 Re-check: PASS**
|
||||||
|
|
||||||
|
- The design extends existing support-layer abstractions instead of adding a parallel policy subsystem.
|
||||||
|
- No database migration, queue job, Graph contract change, panel change, or asset build change is required for the first implementation slice.
|
||||||
|
- Livewire v4 and Filament v5 compliance remain intact, and provider registration continues to live in `bootstrap/providers.php`.
|
||||||
|
- `TenantResource` remains globally searchable through existing View or Edit pages; the design only changes search scoping inputs, not the resource’s page contract.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/148-central-tenant-operability-policy/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── tenant-operability-policy.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── Operations/
|
||||||
|
│ │ ├── Workspaces/
|
||||||
|
│ │ └── ChooseTenant.php
|
||||||
|
│ └── Resources/
|
||||||
|
│ └── TenantResource.php
|
||||||
|
├── Http/
|
||||||
|
│ └── Controllers/
|
||||||
|
├── Models/
|
||||||
|
│ ├── OperationRun.php
|
||||||
|
│ ├── Tenant.php
|
||||||
|
│ ├── TenantOnboardingSession.php
|
||||||
|
│ ├── User.php
|
||||||
|
│ └── UserTenantPreference.php
|
||||||
|
├── Policies/
|
||||||
|
├── Services/
|
||||||
|
│ ├── Auth/
|
||||||
|
│ ├── Onboarding/
|
||||||
|
│ └── Tenants/
|
||||||
|
└── Support/
|
||||||
|
├── Filament/
|
||||||
|
├── Middleware/
|
||||||
|
├── OperateHub/
|
||||||
|
├── Rbac/
|
||||||
|
├── Tenants/
|
||||||
|
└── Workspaces/
|
||||||
|
routes/
|
||||||
|
└── web.php
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Operations/
|
||||||
|
│ ├── Onboarding/
|
||||||
|
│ ├── Rbac/
|
||||||
|
│ └── Filament/
|
||||||
|
└── Unit/
|
||||||
|
└── Tenants/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Use the existing Laravel monolith and strengthen the support layer rather than creating a new policy module outside the current architecture. The implementation seam stays inside `app/Services/Tenants` and `app/Support/Tenants`, with adoption routed through `WorkspaceContext`, `OperateHubShell`, existing Filament resources and pages, and existing auth or UI enforcement helpers.
|
||||||
|
|
||||||
|
## Phase 0 Research Summary
|
||||||
|
|
||||||
|
- The repo already contains a partial centralization layer, but it is not sufficient for Spec 148. `TenantOperabilityService` currently returns lifecycle-derived booleans and only accepts a `Tenant`; it does not accept actor, lane, linked-record context, or capability context.
|
||||||
|
- `TenantActionPolicySurface` is already centralizing labels and action descriptors, which means the right direction is to feed it richer operability outcomes rather than replace it with another action-assembly system.
|
||||||
|
- `OperateHubShell` currently mixes route-authoritative tenant resolution, Filament tenant state, and remembered tenant fallback. Its entitlement logic uses `TenantPageCategory`, but outside tenant-bound routes it still relies on `canSelectAsContext`, which is too narrow for administrative discoverability and canonical linked-record handling.
|
||||||
|
- `ChooseTenant` and `SelectTenantController` duplicate selector-eligibility checks and persistence flow. These should become thin consumers of the shared operability policy and shared remembered-context validation path.
|
||||||
|
- `TenantResource::getGlobalSearchEloquentQuery()` currently reuses `applySelectableScope()`, which means selector eligibility is already influencing discoverability. Spec 148 needs a clear separation between selector lane eligibility and administrative discoverability.
|
||||||
|
- Route safety foundations are already present. `Tenant::resolveRouteBinding()` loads soft-deleted tenants for external IDs, `TenantPageCategory` already classifies canonical and onboarding routes, and `TenantlessOperationRunViewer` already treats tenant mismatch as informational. The feature should formalize and centralize the same semantics instead of re-deriving them locally.
|
||||||
|
- Laravel documentation confirms the intended 404 versus 403 split should use authorization responses such as `Response::denyAsNotFound()` for non-members or non-entitled access and normal Gate authorization for in-scope capability denial.
|
||||||
|
- Filament documentation confirms destructive actions must remain `->action(...)->requiresConfirmation()` and that URL-only actions cannot be confirmation-modal actions. Existing lifecycle actions already align with this and the implementation should preserve that contract.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
### Implementation Approach
|
||||||
|
|
||||||
|
1. Evolve the existing operability seam into the single authority.
|
||||||
|
- Keep `TenantOperabilityService` as the implementation anchor, but expand it from tenant-only booleans into a central policy surface that accepts explicit context.
|
||||||
|
- Preserve backward compatibility temporarily by providing adapter methods for existing consumers while migrating them to structured outcomes.
|
||||||
|
|
||||||
|
2. Add explicit lane-aware and reason-aware domain objects.
|
||||||
|
- Introduce or extend support-layer types for interaction lane, structured input context, structured outcome, and reason codes.
|
||||||
|
- Keep these types in `app/Support/Tenants` so they remain close to existing lifecycle and page-category enums.
|
||||||
|
- `TenantOperabilityContext` carries normalized record, lane, route, and workflow inputs supplied by the consumer, while `TenantOperabilityService` resolves live membership, entitlement, capability, and archived-state facts through existing authoritative helpers so callers do not precompute authorization truth.
|
||||||
|
|
||||||
|
3. Separate operability from authorization while composing them intentionally.
|
||||||
|
- Operability decides whether the requested behavior is meaningful for lifecycle and lane.
|
||||||
|
- Authorization remains server-side truth for membership, entitlement, and capability.
|
||||||
|
- The structured outcome must distinguish at least wrong lane, lifecycle mismatch, non-selector eligibility, missing capability, and non-entitled access.
|
||||||
|
|
||||||
|
4. Centralize consumer adoption around the highest-risk surfaces first.
|
||||||
|
- Selector construction and remembered-context validation.
|
||||||
|
- `OperateHubShell` active-tenant resolution.
|
||||||
|
- `TenantActionPolicySurface` and `TenantResource` action visibility.
|
||||||
|
- Onboarding workflow resumability checks.
|
||||||
|
- Canonical viewer tenant-linked affordance and tenant-bound route support checks.
|
||||||
|
|
||||||
|
5. Split selector eligibility from discoverability.
|
||||||
|
- Replace the current assumption that active-selector membership and administrative visibility are the same semantic decision.
|
||||||
|
- Create distinct policy questions for standard selector eligibility, administrative discoverability, tenant-bound viewability, and canonical reference validity.
|
||||||
|
|
||||||
|
6. Preserve route-authority semantics from Specs 144 and 147.
|
||||||
|
- Tenant-bound pages remain valid from route tenant plus entitlement.
|
||||||
|
- Canonical workspace viewers remain valid from the workspace-owned record plus entitlement.
|
||||||
|
- Selected tenant context remains a filter or convenience input only.
|
||||||
|
|
||||||
|
7. Keep the first slice schema-free and asset-free.
|
||||||
|
- No new persistent table is required for structured outcomes or reason codes.
|
||||||
|
- No new panel, provider registration, or Filament asset work is planned.
|
||||||
|
|
||||||
|
### Planned Workstreams
|
||||||
|
|
||||||
|
- **Workstream A: Operability core model**
|
||||||
|
Extend `TenantOperabilityService` and `TenantOperabilityDecision` into a central lane-aware policy boundary with structured inputs and reasoned outcomes.
|
||||||
|
|
||||||
|
- **Workstream B: Selector and remembered-context adoption**
|
||||||
|
Refactor `ChooseTenant`, `SelectTenantController`, and `WorkspaceContext` to use the new operability contract for standard selector membership and remembered-context validity.
|
||||||
|
|
||||||
|
- **Workstream C: Shell and route adoption**
|
||||||
|
Update `OperateHubShell`, route-adjacent middleware, and tenant-page classification consumers so administrative, tenant-bound, and canonical-record lanes request the right operability question instead of reusing selector booleans.
|
||||||
|
|
||||||
|
- **Workstream D: Action-surface adoption**
|
||||||
|
Refactor `TenantActionPolicySurface` and `TenantResource` lifecycle actions to consume action-specific operability outcomes and reason codes while preserving `UiEnforcement`, confirmation, and audit behavior.
|
||||||
|
|
||||||
|
- **Workstream E: Discoverability and global-search hardening**
|
||||||
|
Separate administrative discoverability from standard selector eligibility for `TenantResource`, choose-tenant, and any shared tenant-picking or search surfaces in scope.
|
||||||
|
|
||||||
|
- **Workstream F: Regression hardening**
|
||||||
|
Add focused Pest coverage for lifecycle state, lane differences, selector membership, remembered-context invalidation, canonical viewer safety, tenant-bound route safety, lifecycle-action eligibility, and capability-denied versus lifecycle-denied distinctions.
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
|
||||||
|
- Add unit tests for the central operability service covering each lifecycle across each lane and asserting structured reason codes for denied outcomes.
|
||||||
|
- Add unit tests for selector eligibility, remembered-context validity, tenant-bound viewability, canonical linked-record viewability, archive eligibility, restore eligibility, and resume-onboarding eligibility.
|
||||||
|
- Add focused feature tests for `ChooseTenant` and `SelectTenantController` to prove only active-lane eligible tenants can be selected.
|
||||||
|
- Add focused feature tests for `WorkspaceContext` or route-level consumers to prove stale remembered context is cleared when the tenant becomes ineligible, missing, or out of workspace.
|
||||||
|
- Add focused feature tests for `/admin/tenants/{tenant}` showing onboarding and archived tenants remain route-valid when authorized even though they are not selector-eligible.
|
||||||
|
- Add focused feature tests for `/admin/operations/{run}` showing canonical viewer legitimacy does not depend on selected tenant context.
|
||||||
|
- Add or update tests around `TenantActionPolicySurface` and `TenantResource` to prove `Archive`, `Restore`, and `Resume onboarding` remain lifecycle-safe and capability-aware.
|
||||||
|
- Add focused tests proving onboarding-completion plus readiness or verification affordances resolve from the central operability layer with capability-aware and lifecycle-aware denial reasons.
|
||||||
|
- Add focused search or resource-query tests proving tenant global search and administrative discoverability do not collapse back to selector-only semantics.
|
||||||
|
- Run the minimum focused Pest suite through Sail; no full suite is required for planning artifacts.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations or exceptional complexity are planned at this stage.
|
||||||
94
specs/148-central-tenant-operability-policy/quickstart.md
Normal file
94
specs/148-central-tenant-operability-policy/quickstart.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Quickstart: Central Tenant Operability Policy
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that one central operability authority now drives selector membership, remembered-context validity, tenant-bound page legitimacy, canonical viewer behavior, and lifecycle-safe action availability.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail.
|
||||||
|
2. Ensure a test workspace exists with tenants covering `draft`, `onboarding`, `active`, and `archived` lifecycle states.
|
||||||
|
3. Ensure at least one onboarding session is linked to a `draft` or `onboarding` tenant.
|
||||||
|
4. Ensure at least one `OperationRun` references a tenant that is not standard-selector eligible.
|
||||||
|
|
||||||
|
## Implementation Validation Order
|
||||||
|
|
||||||
|
### 1. Run focused unit coverage for the policy core
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Tenants
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Operability outcomes are stable across all lifecycle and lane combinations.
|
||||||
|
- Reason codes distinguish lifecycle denial, lane denial, and capability denial.
|
||||||
|
|
||||||
|
### 2. Run focused selector and remembered-context tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact --filter=ChooseTenant
|
||||||
|
vendor/bin/sail artisan test --compact --filter=SelectTenant
|
||||||
|
vendor/bin/sail artisan test --compact --filter=remembered
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Only `active` tenants are selectable in the standard selector.
|
||||||
|
- Stale remembered context is cleared when the tenant becomes missing, out of workspace, or selector-ineligible.
|
||||||
|
|
||||||
|
### 3. Run focused tenant-bound and canonical-view tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Operations
|
||||||
|
vendor/bin/sail artisan test --compact --filter=TenantlessOperationRunViewer
|
||||||
|
vendor/bin/sail artisan test --compact --filter=OperateHubShell
|
||||||
|
vendor/bin/sail artisan test --compact --filter=ViewTenant
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- `/admin/operations/{run}` remains valid under mismatched or empty selected tenant context.
|
||||||
|
- `/admin/tenants/{tenant}` remains valid for authorized onboarding or archived tenants even when they are not selector-eligible.
|
||||||
|
- Shell-level context resolution keeps canonical and tenant-bound routes valid without falling back to selector-lane assumptions.
|
||||||
|
|
||||||
|
### 4. Run focused lifecycle-action tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact --filter=archive
|
||||||
|
vendor/bin/sail artisan test --compact --filter=restore
|
||||||
|
vendor/bin/sail artisan test --compact --filter="resume onboarding"
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact --filter="onboarding completion"
|
||||||
|
vendor/bin/sail artisan test --compact --filter=readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- `Archive`, `Restore`, and `Resume onboarding` resolve from the central operability layer.
|
||||||
|
- Onboarding-completion plus readiness or verification affordances resolve from the same policy boundary with lifecycle-aware and capability-aware reasons.
|
||||||
|
- Non-members remain 404.
|
||||||
|
- In-scope users without capability remain 403.
|
||||||
|
|
||||||
|
### 5. Run focused discoverability and global-search tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact --filter=TenantResourceGlobalSearch
|
||||||
|
vendor/bin/sail artisan test --compact --filter=discoverability
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Administrative discoverability and tenant global search do not collapse back to standard selector eligibility.
|
||||||
|
- Non-active tenants can remain administratively discoverable when policy allows, even though they are not selectable in the active-lane chooser.
|
||||||
|
|
||||||
|
### 6. Manual smoke-check in the browser
|
||||||
|
|
||||||
|
1. Open `/admin/choose-tenant` and confirm only active tenants are selectable.
|
||||||
|
2. Open `/admin/tenants` with no selected tenant and confirm the list remains usable.
|
||||||
|
3. Open `/admin/onboarding` and `/admin/onboarding/{onboardingDraft}` and confirm onboarding-lane actions remain available only when operability allows them.
|
||||||
|
4. Open an archived tenant detail route directly and confirm the page renders when authorized.
|
||||||
|
5. Open `/admin/operations/{run}` for a run linked to an onboarding or archived tenant while a different tenant is selected and confirm the mismatch is informational only.
|
||||||
|
6. Verify lifecycle action buttons plus onboarding-completion and readiness or verification affordances remain honest and confirmation-backed where applicable.
|
||||||
|
|
||||||
|
## Non-Goals For This Slice
|
||||||
|
|
||||||
|
- No schema migration.
|
||||||
|
- No new Graph calls.
|
||||||
|
- No new assets or Filament panel registration changes.
|
||||||
|
- No new `OperationRun` type.
|
||||||
57
specs/148-central-tenant-operability-policy/research.md
Normal file
57
specs/148-central-tenant-operability-policy/research.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Phase 0 Research: Central Tenant Operability Policy
|
||||||
|
|
||||||
|
## Decision: Extend the existing tenant operability seam instead of creating a second policy subsystem
|
||||||
|
|
||||||
|
**Rationale**: The repo already has `TenantOperabilityService`, `TenantOperabilityDecision`, `TenantActionPolicySurface`, `TenantPageCategory`, and `OperateHubShell`. The problem is not lack of abstraction but that the current abstraction is tenant-only and boolean-only. Extending that seam keeps the implementation aligned with the current support-layer architecture and avoids another round of scattered semantics.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Create a brand-new `TenantOperabilityPolicy` tree and deprecate the current service immediately: rejected because it would duplicate existing support-layer concepts and create avoidable migration churn.
|
||||||
|
- Push all behavior into Laravel policies only: rejected because the feature needs lane-aware visibility and selector semantics in addition to authorization outcomes.
|
||||||
|
|
||||||
|
## Decision: Make operability evaluation explicit, actor-aware, and lane-aware
|
||||||
|
|
||||||
|
**Rationale**: Current `TenantOperabilityService::decisionFor()` only receives a `Tenant` and derives booleans from lifecycle plus soft-delete state. Spec 148 requires decisions that also depend on actor, workspace membership, tenant entitlement, route type, onboarding workflow context, and linked record context. That requires an explicit input object or equivalent structured arguments rather than ambient request state.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Continue using request-path inference and auth globals only: rejected because the feature explicitly requires reusable policy evaluation across selectors, pages, controllers, middleware, and future governance surfaces.
|
||||||
|
- Add more helper methods like `canViewForOperations()` and `canViewForOnboarding()`: rejected because method proliferation would keep the same semantic sprawl, only in one class.
|
||||||
|
|
||||||
|
## Decision: Return structured outcomes with reason codes, not only booleans
|
||||||
|
|
||||||
|
**Rationale**: The spec requires reasoned denials and clearer UX. Existing `TenantOperabilityDecision` supports booleans only, which is sufficient for simple visibility but not for distinguishing lifecycle mismatch, wrong lane, missing capability, selector ineligibility, or canonical-record follow-up limits. A structured outcome enables consistent UI messaging, more precise test assertions, and future audit or diagnostics.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep booleans and let each surface derive its own explanations: rejected because that recreates local branching and undermines same-question consistency.
|
||||||
|
- Use free-form reason strings: rejected because stable reason codes are more testable and less likely to drift.
|
||||||
|
|
||||||
|
## Decision: Separate selector eligibility from administrative discoverability
|
||||||
|
|
||||||
|
**Rationale**: The code already shows the risk of conflating these concepts. `ChooseTenant` correctly filters selectable tenants via `TenantOperabilityService`, but `TenantResource::getGlobalSearchEloquentQuery()` also uses `applySelectableScope()`, which means active-selector eligibility is already affecting discoverability. Spec 148 requires selector eligibility, remembered-context validity, administrative discoverability, and canonical reference handling to be distinct policy questions.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Treat selector membership as the universal “valid tenant” rule: rejected because onboarding and archived tenants must remain visible in administrative, onboarding, and canonical record lanes.
|
||||||
|
- Keep administrative visibility ad hoc while only centralizing selector logic: rejected because the spec’s goal is one authoritative tenant-semantic boundary.
|
||||||
|
|
||||||
|
## Decision: Preserve route-authoritative semantics for tenant-bound and canonical viewers
|
||||||
|
|
||||||
|
**Rationale**: Existing code already partially supports this. `Tenant::resolveRouteBinding()` uses `withTrashed()` for tenant external IDs, `TenantPageCategory` distinguishes tenant-bound versus canonical pages, and `TenantlessOperationRunViewer` already frames selected-tenant mismatch as informational. The correct design direction is to formalize these semantics through the central operability policy rather than letting shell and page-level code make their own exceptions.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Force route viewers to align with currently selected tenant context: rejected because Specs 144 and 147 already establish that selected context is not the authority for route legitimacy.
|
||||||
|
- Make canonical viewers tenant-blind: rejected because tenant entitlement still matters for linked record access.
|
||||||
|
|
||||||
|
## Decision: Keep authorization enforcement in Gates or policies and compose operability with it
|
||||||
|
|
||||||
|
**Rationale**: Laravel documentation confirms the expected split: use authorization responses for 404 concealment or 403 denial, and keep server-side authorization authoritative. Operability should decide whether an action makes sense in the tenant’s lifecycle and lane, but it must not become a vague replacement for workspace membership, tenant entitlement, or capability enforcement.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Move all capability checks into the operability service: rejected because that would blur authorization boundaries and make policy outcomes harder to reason about.
|
||||||
|
- Keep authorization entirely separate from operability with no shared contract: rejected because action surfaces and route consumers still need one semantic answer that combines both concerns intentionally.
|
||||||
|
|
||||||
|
## Decision: Preserve existing Filament action safety rules and global-search requirements
|
||||||
|
|
||||||
|
**Rationale**: Filament documentation confirms destructive actions must use `->action(...)->requiresConfirmation()` and that confirmation is not available on URL-only actions. The repo already has `Archive` and `Restore` modeled as action closures with confirmation, and `TenantResource` already has View or Edit pages, which keeps it eligible for global search under Filament v5. The implementation should preserve these contracts while changing only the decision authority behind visibility and eligibility.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Convert lifecycle actions into URL-only redirects with local confirmation copy: rejected because Filament confirmation modals require action closures.
|
||||||
|
- Disable global search to avoid selector or discoverability complexity: rejected because the feature needs better semantics, not narrower product behavior.
|
||||||
204
specs/148-central-tenant-operability-policy/spec.md
Normal file
204
specs/148-central-tenant-operability-policy/spec.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# Feature Specification: Central Tenant Operability Policy
|
||||||
|
|
||||||
|
**Feature Branch**: `148-central-tenant-operability-policy`
|
||||||
|
**Created**: 2026-03-14
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Tenant lifecycle, action visibility, selector inclusion, tenant-bound route behavior, and canonical record viewing are currently governed by scattered local checks such as lifecycle string comparisons, soft-delete checks, ad hoc entitlement queries, and page-specific assumptions. The product needs one central operability policy layer that translates tenant lifecycle plus authorization context into explicit, reusable decisions."
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/tenants`
|
||||||
|
- `/admin/tenants/{tenant}`
|
||||||
|
- `/admin/choose-tenant`
|
||||||
|
- `/admin/onboarding`
|
||||||
|
- `/admin/onboarding/{onboardingDraft}`
|
||||||
|
- `/admin/operations`
|
||||||
|
- `/admin/operations/{run}`
|
||||||
|
- Any in-scope surface that decides tenant visibility, action availability, context eligibility, or tenant-linked record access
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenants remain workspace-owned records.
|
||||||
|
- Tenant lifecycle remains defined by Spec 143.
|
||||||
|
- Operation runs remain canonical workspace-level records with optional tenant references.
|
||||||
|
- Onboarding workflow records remain separate workspace-scoped workflow records that may link to tenants where applicable.
|
||||||
|
- This feature governs the decision layer that evaluates what is allowed, visible, selectable, viewable, or operable for a tenant in a given context.
|
||||||
|
- This feature does not change workspace or tenant ownership boundaries and does not introduce a second tenant model.
|
||||||
|
- **RBAC**:
|
||||||
|
- Authorization planes involved: admin `/admin` workspace-scoped routes, tenant-bound routes, onboarding workflow routes, and canonical workspace-level viewers that may reference a tenant.
|
||||||
|
- Non-members or actors lacking workspace scope or tenant entitlement for the record in question receive deny-as-not-found behavior.
|
||||||
|
- Members who can reach the surface but lack the capability for a specific tenant action or lifecycle mutation receive forbidden behavior.
|
||||||
|
- Selected tenant context is convenience state only and must never replace workspace membership, tenant entitlement, capability checks, or route identity.
|
||||||
|
- All in-scope authorization behavior must rely on the canonical capability registry and central server-side enforcement rather than raw role strings or local visibility-only checks.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: Workspace-level indexes such as `/admin/operations` may prefilter to the selected tenant as a convenience, but canonical viewer legitimacy and tenant-linked record validity must not depend on current selected tenant context.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical viewers must authorize from the workspace-owned record, the record's workspace relationship, the actor's workspace membership, and tenant entitlement for any referenced tenant. The operability layer may influence tenant follow-up actions, but it must not leak unauthorized tenant details or reintroduce selected-tenant-equality gating.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Get One Authoritative Tenant Decision (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace operator, I need the product to answer the same tenant-semantic question the same way across selectors, actions, tenant pages, and linked record viewers, so that lifecycle and access rules stop changing from surface to surface.
|
||||||
|
|
||||||
|
**Why this priority**: Cross-surface inconsistency is the root failure. Until one central authority exists, every downstream surface can drift and reintroduce contradictory tenant behavior.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating tenants in `draft`, `onboarding`, `active`, and `archived` states and verifying that selector eligibility, archive or restore availability, onboarding resume availability, and tenant-bound page viewability all resolve from one shared decision source instead of page-local exceptions.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the same `active` tenant appears on the selector, tenant index, and tenant detail page, **When** each surface asks whether the tenant is eligible for normal active context or archive, **Then** each surface receives the same operability answer.
|
||||||
|
2. **Given** the same `onboarding` tenant appears on onboarding and tenant-management surfaces, **When** each surface asks whether onboarding may be resumed, **Then** the answer remains consistent across those surfaces.
|
||||||
|
3. **Given** the same `archived` tenant appears in an administrative list and as a reference on a canonical record, **When** each surface asks whether restore is valid and whether the tenant is selector-eligible, **Then** the policy returns distinct but consistent lane-aware answers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Keep Valid Records Viewable Without False Tenant Context Gates (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace operator, I need tenant-bound routes and canonical workspace-level viewers to stay valid based on route subject and entitlement rather than remembered tenant context, so that deep links, investigations, and administrative review remain trustworthy.
|
||||||
|
|
||||||
|
**Why this priority**: Canonical viewers and tenant-bound routes are high-trust surfaces. False invalidity caused by header state undermines the workspace-first model and blocks investigation.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening tenant detail routes and canonical operation-run routes while selected tenant context is mismatched, invalid, or empty and confirming the route still resolves when entitlement allows it.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an authorized user opens `/admin/operations/{run}` for a run linked to tenant A while tenant B is selected, **When** the page loads, **Then** the run remains viewable and the tenant mismatch is treated as context only.
|
||||||
|
2. **Given** an authorized user opens `/admin/tenants/{tenant}` for an onboarding or archived tenant that is excluded from the standard selector, **When** the page loads, **Then** the tenant page remains valid because the route tenant and policy are authoritative.
|
||||||
|
3. **Given** a user lacks workspace membership or tenant entitlement for a route-resolved record, **When** the user opens the route directly, **Then** deny-as-not-found behavior applies and the operability layer does not broaden access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Show Only Lifecycle-Safe Actions And Selections (Priority: P3)
|
||||||
|
|
||||||
|
As a workspace operator, I need selectors and action surfaces to offer only lifecycle-safe choices for the tenant's current lane, so that I do not see actions or context options the product will reject later.
|
||||||
|
|
||||||
|
**Why this priority**: Honest selectors and honest action visibility reduce operator confusion and are the most visible outcome of correct operability policy.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by preparing tenants in each lifecycle state and verifying that standard selector membership, archive or restore availability, resume-onboarding availability, and readiness or verification affordances match policy expectations in each lane.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant is `draft` or `onboarding`, **When** the operator opens the standard active selector, **Then** the tenant is not selectable there.
|
||||||
|
2. **Given** a tenant is `active`, **When** the operator views tenant-management surfaces, **Then** normal operating-lane affordances may be available and onboarding-only actions are not shown.
|
||||||
|
3. **Given** a tenant is `archived`, **When** the operator views administrative recovery surfaces, **Then** restore may be offered when authorized and normal active-lane affordances are not shown.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A remembered tenant was valid when stored but later becomes `draft`, `onboarding`, or `archived`; the active-lane remembered context must be invalidated without breaking workspace-level pages.
|
||||||
|
- A canonical workspace-level record references an onboarding or archived tenant; the record remains viewable when authorization permits even though the tenant is not active-selector eligible.
|
||||||
|
- A tenant has linked onboarding workflow records but is already `active`; onboarding-only actions must stay unavailable outside explicit workflow context.
|
||||||
|
- A tenant transitions lifecycle while an operator is already on a tenant-bound page; the page remains route-valid, but action availability and lane affordances refresh to the new policy outcome.
|
||||||
|
- A tenant is administratively discoverable but the actor lacks the capability for archive, restore, or resume-onboarding; the tenant remains viewable where allowed while the action itself stays unavailable or forbidden if reached directly.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, new long-running jobs, or new `OperationRun` types. It defines a central domain-policy layer that future and current in-scope consumers must use when resolving tenant visibility, action validity, context eligibility, and tenant-linked route access. Any lifecycle mutation that consumes this policy remains subject to existing confirmation, audit, tenant-isolation, and observability rules defined elsewhere.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** This feature does not create or mutate an `OperationRun`. It preserves the existing three-surface Ops-UX contract and clarifies that canonical viewers remain record-authoritative. Any existing or future `OperationRun`-backed surface that consumes operability decisions must continue to rely on service-owned status and outcome transitions and must not use operability as a substitute for run lifecycle ownership.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature changes authorization behavior in the admin `/admin` plane and the tenant-context behavior inside that plane. No platform `/system` behavior is broadened. Cross-plane access remains deny-as-not-found when out of scope. In this feature, 404 means the actor is not a workspace member or is not entitled to the workspace or tenant scope of the requested record; 403 means the actor is in scope but lacks a required capability for an otherwise valid action or route. Authorization must be enforced server-side through central policies, Gates, or equivalent authoritative services using the canonical capability registry. Global search and record discovery must remain non-member-safe and tenant-safe. Destructive-like actions such as `Archive` and `Restore` remain confirmation-required. Validation must include positive and negative authorization coverage plus capability-denied versus lifecycle-denied distinctions.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not alter authentication handshake behavior or permit synchronous auth-path exceptions to substitute for monitoring or operations rules.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** This feature may influence whether lifecycle-aware reasons and statuses are shown on surfaces, but lifecycle badge and label semantics remain centralized through the shared tenant-status presentation contract defined by Spec 146.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target objects are tenant records, linked onboarding workflow records, and canonical workspace records with tenant references. Primary operator verbs remain `Archive`, `Restore`, `Resume onboarding`, `View`, and lane-appropriate inspection verbs. Operability explanations must preserve the distinction between selected tenant, route tenant, referenced tenant, lifecycle state, and capability denial. Implementation-first phrases such as `soft delete` or `not trashed` must not become primary operator-facing language.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** This feature modifies the decision authority behind existing Filament and shell-adjacent surfaces. The Action Surface Contract is satisfied when affected screens consume the central operability answers, keep destructive-like lifecycle actions confirmation-protected, and stop relying on resource-local lifecycle predicates as the final authority. The matrix below documents the in-scope action surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature changes policy semantics rather than page layout. Existing Create, Edit, View, and Table layouts remain intact. UX-001 remains satisfied so long as affected surfaces preserve their existing layout structure while consuming the centralized policy answers for visibility, actions, and mismatch messaging.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-148-001**: The system MUST provide one explicit, authoritative tenant operability policy layer for in-scope tenant-semantic decisions.
|
||||||
|
- **FR-148-002**: The central operability policy MUST evaluate decisions using an explicit evaluation contract that combines consumer-supplied context and authoritative service-resolved facts. That contract MUST cover the tenant record, tenant lifecycle, workspace relationship, actor, requested interaction lane or route context, linked record context where applicable, onboarding workflow state where required, and any authoritative membership, entitlement, capability, or archived persistence facts needed for the decision.
|
||||||
|
- **FR-148-003**: The central operability policy MUST support lane-aware evaluation for at least the standard active operating lane, onboarding workflow lane, administrative tenant-management lane, and canonical workspace-level record lane.
|
||||||
|
- **FR-148-004**: The central operability policy MUST centrally answer whether a tenant is eligible for the standard active selector.
|
||||||
|
- **FR-148-005**: The central operability policy MUST centrally answer whether remembered tenant context remains valid for normal active-lane use.
|
||||||
|
- **FR-148-006**: The central operability policy MUST centrally answer whether a tenant-bound route or tenant-bound page is viewable for a given actor and lane.
|
||||||
|
- **FR-148-007**: The central operability policy MUST centrally answer whether a canonical workspace-level record linked to a tenant remains viewable and what tenant follow-up affordances remain valid.
|
||||||
|
- **FR-148-008**: The central operability policy MUST centrally answer whether lifecycle actions such as `Archive`, `Restore`, and `Resume onboarding` are available for the tenant in the current lane.
|
||||||
|
- **FR-148-009**: The central operability policy MUST centrally answer whether onboarding-completion and readiness or verification actions are meaningful for the tenant in the current workflow or management context.
|
||||||
|
- **FR-148-010**: The central operability policy MUST centrally answer whether a tenant remains administratively discoverable even when it is not eligible for standard active selection.
|
||||||
|
- **FR-148-011**: Operability decisions MUST incorporate both authorization context and lifecycle or context semantics. Neither lifecycle alone nor capability alone may serve as the complete decision rule.
|
||||||
|
- **FR-148-012**: New or changed in-scope code MUST NOT use raw lifecycle checks, raw soft-delete checks, selector query filters, or selected-tenant equality as the final authority for operability decisions when the central policy applies.
|
||||||
|
- **FR-148-013**: If two in-scope consumers ask the same semantic question, the system MUST return the same operability answer even if the surfaces present that answer differently.
|
||||||
|
- **FR-148-014**: The central operability policy MUST preserve the rule that selected tenant context is convenience state and must not be the final authority for route legitimacy or canonical linked-record access.
|
||||||
|
- **FR-148-015**: For `draft` tenants, the policy MUST support administrative viewability and onboarding-lane activity while denying standard active-selector eligibility, archive, and restore.
|
||||||
|
- **FR-148-016**: For `onboarding` tenants, the policy MUST support onboarding-lane activity, administrative viewability, and resumable onboarding where authorized while denying standard active-selector eligibility, archive, and restore.
|
||||||
|
- **FR-148-017**: For `active` tenants, the policy MUST support standard active-selector eligibility when entitled, normal operating-lane affordances, and archive availability where authorized while denying onboarding-only actions and restore.
|
||||||
|
- **FR-148-018**: For `archived` tenants, the policy MUST deny standard active-selector eligibility and normal operating-lane affordances while allowing administrative or audit-lane viewability and restore where authorized.
|
||||||
|
- **FR-148-019**: The central operability policy SHOULD provide structured outcomes that can express allowed or denied state plus reason information sufficient to distinguish capability denial, lifecycle mismatch, wrong lane, selector ineligibility, and invalid lifecycle-action combinations.
|
||||||
|
- **FR-148-020**: Structured operability outcomes MUST be usable by selectors, remembered-context validation, tenant table actions, tenant detail actions, tenant-bound access checks, onboarding workflow surfaces, canonical viewers, and future governance or reporting surfaces.
|
||||||
|
- **FR-148-021**: The central operability policy MUST support gradual adoption so existing consumers can migrate incrementally without introducing a parallel second semantics system.
|
||||||
|
- **FR-148-022**: In-scope implementations MUST reduce repeated tenant-semantic branching in pages, resources, helpers, middleware, and shell logic by moving equivalent decisions to the central policy boundary.
|
||||||
|
- **FR-148-023**: The operability layer MUST complement real authorization rather than weaken it; it must never broaden access to tenant or workspace records beyond existing membership, entitlement, and capability rules.
|
||||||
|
- **FR-148-024**: Validation coverage for this feature MUST include each canonical lifecycle state, selector eligibility, remembered-context validity, tenant-bound viewability, canonical linked-record compatibility, archive eligibility, restore eligibility, resume-onboarding eligibility, readiness-action eligibility where relevant, and capability-denied versus lifecycle-denied outcomes.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
|
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace shell and standard tenant selector | `/admin`, `/admin/choose-tenant` | Existing workspace and tenant-context affordances only | Existing tenant chooser entries | `Select tenant` for standard active-lane eligible tenants only | None | Existing fallback CTA to managed tenants or workspace surfaces remains | Not applicable | Not applicable | No direct mutation | The operability policy becomes the authority for selector inclusion and remembered-context validity. |
|
||||||
|
| Managed tenants index | `/admin/tenants` | Existing create or onboarding-entry actions remain as currently defined | Existing row click or `View` affordance | `Resume onboarding`, `Archive`, or `Restore` depending on policy; additional actions remain overflowed | Existing grouped bulk actions unchanged unless later specs harden them | Existing tenant creation or onboarding CTA remains | Not applicable | Existing save and cancel behavior unchanged | Yes for lifecycle mutations | `Archive` and `Restore` remain destructive-like and require confirmation plus capability enforcement. |
|
||||||
|
| Tenant detail page | `/admin/tenants/{tenant}` | No new header actions are introduced by this spec | Route-record inspection | Not applicable | None | Not applicable | `Archive`, `Restore`, `Resume onboarding`, and related inspection actions only when the central policy says they are valid | Existing save and cancel behavior unchanged | Yes for lifecycle mutations | Route legitimacy is resolved from the route tenant and policy, not selected-tenant equality. |
|
||||||
|
| Onboarding workflow pages | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | Existing onboarding entry actions remain | Existing onboarding record inspection | `Resume onboarding` and related workflow actions when valid | None | Existing onboarding CTA remains | `View tenant` and workflow-appropriate actions when valid | Existing save and cancel behavior unchanged | Yes when workflow state mutates | Onboarding surfaces consume the same operability answers but remain workflow-lane pages, not active-lane selectors. |
|
||||||
|
| Operations index and canonical run viewer | `/admin/operations`, `/admin/operations/{run}` | Existing navigation and filtering affordances remain | Existing run inspection links remain | `View run` remains canonical inspect action | Existing grouped bulk actions unchanged | Existing empty state unchanged | Existing run-view actions unchanged | Not applicable | Existing audit rules unchanged | Exemption: this spec changes viewer legitimacy and tenant-linked affordance policy, not the run mutation inventory. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Tenant Operability Policy**: The authoritative domain-policy layer that translates lifecycle, authorization context, and interaction lane into explicit tenant decisions.
|
||||||
|
- **Interaction Lane**: The product context in which a tenant is being evaluated, including active operating, onboarding workflow, administrative management, and canonical workspace-record viewing.
|
||||||
|
- **Tenant**: A durable workspace-owned record with canonical lifecycle semantics that influence, but do not fully determine, operability.
|
||||||
|
- **Onboarding Workflow Record**: A workspace-scoped workflow record linked to a tenant where applicable and used to evaluate onboarding-specific operability decisions.
|
||||||
|
- **Canonical Workspace Record**: A workspace-owned record such as an operation run that may reference a tenant while remaining authoritative on its own route.
|
||||||
|
- **Remembered Tenant Context**: A workspace-scoped convenience preference for the active operating lane that must be revalidated through the central operability policy before use.
|
||||||
|
- **Operability Outcome**: A centralized decision result that communicates whether a tenant is eligible, viewable, or actionable in a given lane and why.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-148-001**: In focused regression coverage for in-scope consumers, 100% of shared tenant-semantic decisions tested across at least two surfaces return consistent outcomes for the same tenant, actor, and lane.
|
||||||
|
- **SC-148-002**: In focused regression coverage, 0 `draft`, `onboarding`, or `archived` tenants appear as selectable choices in the standard active selector unless a future spec explicitly redefines selector policy.
|
||||||
|
- **SC-148-003**: In focused regression coverage, 100% of authorized canonical record links and tenant-bound routes remain accessible when selected tenant context is mismatched, stale, or empty.
|
||||||
|
- **SC-148-004**: In focused regression coverage, 100% of lifecycle-changing and onboarding-resume actions tested resolve from the central operability layer rather than page-local lifecycle shortcuts.
|
||||||
|
- **SC-148-005**: In focused authorization coverage, 100% of non-member or non-entitled access attempts resolve as deny-as-not-found and 100% of in-scope member-without-capability cases resolve as forbidden.
|
||||||
|
- **SC-148-006**: In focused validation of denied or ineligible states, every covered denial path communicates a structured lifecycle-, lane-, or capability-aware reason suitable for clearer UX and regression assertions.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 143 remains the source of truth for tenant lifecycle semantics and page-type distinctions.
|
||||||
|
- Spec 144 remains the source of truth for canonical operation-viewer legitimacy under tenant-context mismatch.
|
||||||
|
- Spec 145 remains the source of truth for lifecycle-safe tenant action taxonomy.
|
||||||
|
- Spec 146 remains the source of truth for centralized tenant-status and lifecycle presentation.
|
||||||
|
- Spec 147 remains the source of truth for selector semantics and remembered tenant-context enforcement.
|
||||||
|
- This feature is a consolidation layer that centralizes semantics already implied by those specs rather than introducing a new tenant or authorization model.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 143 — Tenant Lifecycle, Operability, and Context Semantics Foundation
|
||||||
|
- Spec 144 — Canonical Operation Viewer Decoupled from Remembered Tenant Context
|
||||||
|
- Spec 145 — Tenant Action Taxonomy and Lifecycle-Safe Visibility
|
||||||
|
- Spec 146 — Central Tenant Status Presentation
|
||||||
|
- Spec 147 — Tenant Selector and Remembered Context Enforcement
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- A boolean-only policy surface could centralize decisions without improving diagnostics, operator messaging, or test clarity.
|
||||||
|
- Partial consumer migration could leave some legacy local checks in place and delay full consistency.
|
||||||
|
- Overloading the operability layer with presentation or persistence concerns could turn it into a generic dumping ground rather than a clean semantic boundary.
|
||||||
|
- Treating operability as a replacement for authorization rather than as a complement could weaken the current security model.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Tenant behavior is currently enforced through scattered lifecycle comparisons, soft-delete checks, selector filters, page-local visibility rules, and route-specific assumptions. This makes the same tenant behave differently depending on surface, hides policy bugs behind local shortcuts, and makes future lifecycle-aware work too easy to implement incorrectly.
|
||||||
|
|
||||||
|
This feature establishes one central tenant operability policy layer whose job is to answer the recurring product question: what may this actor do with this tenant in this lane, and why? That central policy becomes the bridge between lifecycle meaning, authorization context, selector behavior, tenant-bound routes, canonical linked-record viewers, and lifecycle-safe action availability.
|
||||||
214
specs/148-central-tenant-operability-policy/tasks.md
Normal file
214
specs/148-central-tenant-operability-policy/tasks.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# Tasks: Central Tenant Operability Policy
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/148-central-tenant-operability-policy/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Runtime behavior changes in this repo require Pest coverage. This feature changes runtime behavior across selector, shell, tenant-management, onboarding, and canonical-view surfaces, so tests are required for every user story.
|
||||||
|
**Operations**: This feature does not introduce new long-running, remote, queued, or scheduled work. No new `OperationRun` creation or lifecycle changes are required.
|
||||||
|
**RBAC**: This feature changes authorization-adjacent tenant semantics in the admin `/admin` plane. Tasks below preserve `404` for non-members or non-entitled actors and `403` for in-scope capability denial, require canonical capability-registry usage, and include positive and negative authorization regression coverage.
|
||||||
|
**UI Naming**: Operability copy and action labels must preserve the distinction between `selected tenant`, `route tenant`, `run tenant`, `Archive`, `Restore`, and `Resume onboarding`, with no implementation-first wording in primary UI copy.
|
||||||
|
**Filament UI Action Surfaces**: This feature modifies existing Filament resources and pages. Tasks below keep existing header, row, bulk, and empty-state inventories intact while moving their decision authority to the central operability layer. Destructive actions remain `->action(...)->requiresConfirmation()`.
|
||||||
|
**Filament UI UX-001**: This feature is not a layout redesign. Tasks below keep existing layouts intact while hardening action visibility, discoverability, and mismatch messaging inside current screens.
|
||||||
|
**Badges**: Existing centralized lifecycle presentation from Spec 146 must remain in use anywhere lifecycle or selector availability is shown.
|
||||||
|
**Contract Artifact**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/148-central-tenant-operability-policy/contracts/tenant-operability-policy.openapi.yaml` is an internal design contract for the operability boundary and route semantics, not a requirement to add new public controller endpoints.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Prepare the regression targets and implementation touchpoints for central operability work.
|
||||||
|
|
||||||
|
- [X] T001 [P] Create or extend the central operability unit test targets in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityServiceTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityOutcomeTest.php`
|
||||||
|
- [X] T002 [P] Create or extend selector and remembered-context feature regression targets in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Workspaces/ChooseTenantPageTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Workspaces/SelectTenantControllerTest.php`
|
||||||
|
- [X] T003 [P] Create or extend tenant-bound and canonical route regression targets in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||||
|
- [X] T004 [P] Create or extend lifecycle-action and discoverability regression targets in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Build the central operability boundary that all user stories depend on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T005 Create the new lane-aware support types in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantInteractionLane.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantOperabilityQuestion.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantOperabilityReasonCode.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantOperabilityContext.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantOperabilityOutcome.php`
|
||||||
|
- [X] T006 Refactor `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantOperabilityDecision.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantLifecycle.php` to expose structured operability helpers and backward-compatible adapters for existing consumers
|
||||||
|
- [X] T007 Implement the actor-aware, lane-aware central evaluation flow in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantOperabilityService.php`
|
||||||
|
- [X] T008 [P] Centralize remembered-context validation and page-category to lane mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Workspaces/WorkspaceContext.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantPageCategory.php`
|
||||||
|
- [X] T009 [P] Add foundational unit coverage for structured operability outcomes, reason codes, and lifecycle-lane combinations in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityServiceTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityOutcomeTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready. The repo has one central operability boundary, and user stories can now adopt it independently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Get One Authoritative Tenant Decision (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make the product answer the same tenant-semantic question the same way across selectors, action surfaces, tenant pages, and onboarding-linked surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Prepare `draft`, `onboarding`, `active`, and `archived` tenants and verify that selector eligibility, archive or restore availability, onboarding resume availability, and tenant-management visibility all resolve from the same central policy source.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T010 [P] [US1] Add selector and lifecycle-action consistency coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Workspaces/ChooseTenantPageTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`
|
||||||
|
- [X] T011 [P] [US1] Add onboarding-versus-admin consistency coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php`
|
||||||
|
- [X] T012 [P] [US1] Add capability-positive and capability-negative action-resolution coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantResourceAuthorizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T013 [US1] Refactor `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/ChooseTenant.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Http/Controllers/SelectTenantController.php` to consume central selector and remembered-context operability outcomes
|
||||||
|
- [X] T014 [US1] Refactor `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantActionPolicySurface.php` to resolve archive, restore, resume-onboarding, and related discoverability decisions from the new operability boundary
|
||||||
|
- [X] T015 [US1] Refactor `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ViewTenant.php` to use the same central answers for row actions, header actions, and administrative visibility
|
||||||
|
- [X] T016 [US1] Align onboarding workflow consumers with the same operability questions in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Policies/TenantOnboardingSessionPolicy.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is complete when the same tenant-semantic question returns the same answer across selector, admin, and onboarding surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Keep Valid Records Viewable Without False Tenant Context Gates (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Preserve route-authoritative behavior for tenant-bound pages and canonical workspace viewers even when selected tenant context differs, is stale, or is empty.
|
||||||
|
|
||||||
|
**Independent Test**: Open authorized `/admin/tenants/{tenant}` and `/admin/operations/{run}` routes with mismatched, stale, or empty selected tenant state and verify the pages still resolve while non-member and non-entitled cases remain `404`.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T017 [P] [US2] Add tenant-bound route-authority and deny-as-not-found coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php`
|
||||||
|
- [X] T018 [P] [US2] Add canonical viewer mismatch, empty-context, entitlement, and capability coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/OperateHubShellTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T019 [US2] Refine `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OperateHub/OperateHubShell.php` to ask the central operability boundary for tenant-bound, canonical-view, and administrative-lane decisions instead of reusing selector booleans
|
||||||
|
- [X] T020 [US2] Remove selector-lane assumptions from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Middleware/EnsureFilamentTenantSelected.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/routes/web.php` so route legitimacy is driven by route subject plus policy
|
||||||
|
- [X] T021 [US2] Update canonical run follow-up affordance gating and mismatch messaging in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
|
||||||
|
- [X] T022 [US2] Align tenant-bound admin page context resolution with route-authoritative operability checks in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Concerns/ResolvesPanelTenantContext.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is complete when tenant-bound and canonical routes stay valid because the route subject is valid, not because selected tenant state matches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Show Only Lifecycle-Safe Actions And Selections (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Separate standard selector eligibility from administrative discoverability and keep lifecycle-safe selections and actions honest across surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Prepare tenants in each lifecycle state and verify that only active tenants are selectable in the standard selector, that non-active tenants remain administratively discoverable where intended, that tenant global search follows administrative discoverability rather than selector eligibility, and that archive, restore, resume-onboarding, and readiness affordances remain lifecycle-safe with capability-aware denials.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T023 [P] [US3] Add selector-versus-discoverability regression coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php`
|
||||||
|
- [X] T024 [P] [US3] Add capability-denied versus lifecycle-denied regression coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantResourceAuthorizationTest.php`
|
||||||
|
- [X] T025 [P] [US3] Add tenant global-search, administrative-discoverability, onboarding-completion, and readiness-or-verification-affordance regression coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/TenantResourceGlobalSearchTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T026 [US3] Separate selector eligibility from administrative discoverability in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantOperabilityService.php`
|
||||||
|
- [X] T027 [US3] Update remembered-context invalidation and explicit clear flows in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Workspaces/WorkspaceContext.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Http/Controllers/ClearTenantContextController.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Http/Controllers/SelectTenantController.php`
|
||||||
|
- [X] T028 [US3] Normalize lifecycle-safe action, onboarding-completion, and readiness-or-verification affordances in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantActionPolicySurface.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/EditTenant.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [X] T029 [US3] Align selector and discoverability copy with central operability reasons in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/choose-tenant.blade.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/partials/context-bar.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is complete when active-lane selection, administrative discoverability, and lifecycle-safe action availability no longer contradict each other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finalize regression coverage, formatting, and manual validation across all stories.
|
||||||
|
|
||||||
|
- [X] T030 [P] Run the focused Pest suites from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/148-central-tenant-operability-policy/quickstart.md` covering `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityServiceTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityOutcomeTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Workspaces/ChooseTenantPageTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Workspaces/SelectTenantControllerTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/OperateHubShellTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/TenantResourceGlobalSearchTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
|
||||||
|
- [X] T031 Run formatting for touched files with `vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [X] T032 [P] Validate the manual smoke checklist in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/148-central-tenant-operability-policy/quickstart.md` against `/admin/choose-tenant`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/admin/tenants`, `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}`, and `/admin/operations/{run}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1: Setup** has no dependencies and can start immediately.
|
||||||
|
- **Phase 2: Foundational** depends on Phase 1 and blocks all user story work.
|
||||||
|
- **Phase 3: User Story 1** depends on Phase 2 and delivers the MVP.
|
||||||
|
- **Phase 4: User Story 2** depends on Phase 2 and is safest after User Story 1 because it reuses the same central operability boundary on route-authoritative surfaces.
|
||||||
|
- **Phase 5: User Story 3** depends on Phase 2 and benefits from User Story 1’s shared action and selector adoption.
|
||||||
|
- **Phase 6: Polish** depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)** can start immediately after the foundational phase and is the MVP slice.
|
||||||
|
- **User Story 2 (P2)** depends on the foundational phase and reuses the operability boundary stabilized in User Story 1.
|
||||||
|
- **User Story 3 (P3)** depends on the foundational phase and benefits from the selector and action adoption work from User Story 1.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write or extend tests first and confirm they fail before implementation.
|
||||||
|
- Shared support-layer changes land before surface refactors.
|
||||||
|
- Route and middleware behavior should stabilize before final copy or mismatch-messaging adjustments.
|
||||||
|
- Story-level regression coverage should pass before moving to the next priority story.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001`, `T002`, `T003`, and `T004` can run in parallel because they prepare separate regression targets.
|
||||||
|
- `T008` and `T009` can run in parallel after `T005`, `T006`, and `T007` define the shared operability model.
|
||||||
|
- `T010`, `T011`, and `T012` can run in parallel within User Story 1.
|
||||||
|
- `T017` and `T018` can run in parallel within User Story 2.
|
||||||
|
- `T023`, `T024`, and `T025` can run in parallel within User Story 3.
|
||||||
|
- `T030` and `T032` can run in parallel after implementation is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the P1 regression additions together:
|
||||||
|
Task: "Add selector and lifecycle-action consistency coverage in tests/Feature/Workspaces/ChooseTenantPageTest.php and tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php"
|
||||||
|
Task: "Add onboarding-versus-admin consistency coverage in tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php and tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php"
|
||||||
|
Task: "Add capability-positive and capability-negative action-resolution coverage in tests/Feature/Rbac/TenantResourceAuthorizationTest.php and tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Split route-authority coverage by surface type:
|
||||||
|
Task: "Add tenant-bound route-authority and deny-as-not-found coverage in tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php and tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php"
|
||||||
|
Task: "Add canonical viewer mismatch, empty-context, entitlement, and capability coverage in tests/Feature/Operations/TenantlessOperationRunViewerTest.php and tests/Feature/OpsUx/OperateHubShellTest.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Split selector/discoverability and denial-reason coverage:
|
||||||
|
Task: "Add selector-versus-discoverability regression coverage in tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php and tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php"
|
||||||
|
Task: "Add capability-denied versus lifecycle-denied regression coverage in tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php and tests/Feature/Rbac/TenantResourceAuthorizationTest.php"
|
||||||
|
Task: "Add tenant global-search, administrative-discoverability, onboarding-completion, and readiness-or-verification-affordance regression coverage in tests/Feature/Filament/TenantResourceGlobalSearchTest.php, tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php, and tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. **Stop and validate** that selector, action, and onboarding consumers now resolve the same tenant-semantic questions centrally.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Deliver User Story 1 to establish one authoritative operability answer across the highest-risk surfaces.
|
||||||
|
2. Deliver User Story 2 to harden route-authoritative tenant-bound and canonical viewers.
|
||||||
|
3. Deliver User Story 3 to eliminate selector-versus-discoverability drift and finalize lifecycle-safe visibility.
|
||||||
|
4. Finish with Phase 6 regression and manual validation.
|
||||||
|
|
||||||
|
### Team Strategy
|
||||||
|
|
||||||
|
1. One engineer owns the foundational support-layer work in `app/Support/Tenants`, `app/Services/Tenants`, and `app/Support/Workspaces`.
|
||||||
|
2. A second engineer can prepare the User Story 1 regression coverage in parallel once the support-layer contracts are clear.
|
||||||
|
3. Route-authority hardening for canonical and tenant-bound pages can proceed as a separate stream after the foundation lands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks touch separate files and can be executed in parallel.
|
||||||
|
- Each user story remains independently testable after the foundational phase.
|
||||||
|
- This feature does not add schema changes, Graph calls, new assets, or new operations workflows.
|
||||||
|
- Keep route legitimacy tied to route subject plus authorization, never to raw remembered tenant session state.
|
||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -91,4 +93,40 @@ public function test_does_not_show_legacy_admin_details_cta_and_keeps_canonical_
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('View run');
|
->assertSee('View run');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_hides_tenant_scoped_follow_up_links_when_the_run_tenant_is_not_selector_eligible(): void
|
||||||
|
{
|
||||||
|
$activeTenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Viewer Active Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||||
|
'name' => 'Viewer Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $onboardingTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $onboardingTenant->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Open')
|
||||||
|
->assertSee('Operations')
|
||||||
|
->assertDontSee(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $onboardingTenant), false)
|
||||||
|
->assertSee('Some tenant follow-up actions may be unavailable from this canonical workspace view.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,12 +69,17 @@
|
|||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'name' => 'Discoverable Onboarding Tenant',
|
'name' => 'Discoverable Onboarding Tenant',
|
||||||
]);
|
]);
|
||||||
|
$draftTenant = Tenant::factory()->draft()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Discoverable Draft Tenant',
|
||||||
|
]);
|
||||||
$archivedTenant = Tenant::factory()->archived()->create([
|
$archivedTenant = Tenant::factory()->archived()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'name' => 'Discoverable Archived Tenant',
|
'name' => 'Discoverable Archived Tenant',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$draftTenant->getKey() => ['role' => 'owner'],
|
||||||
$onboardingTenant->getKey() => ['role' => 'owner'],
|
$onboardingTenant->getKey() => ['role' => 'owner'],
|
||||||
$archivedTenant->getKey() => ['role' => 'owner'],
|
$archivedTenant->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
@ -83,6 +88,60 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
|
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
|
->assertSee('Discoverable Draft Tenant')
|
||||||
->assertSee('Discoverable Onboarding Tenant')
|
->assertSee('Discoverable Onboarding Tenant')
|
||||||
->assertSee('Discoverable Archived Tenant');
|
->assertSee('Discoverable Archived Tenant');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps administrative landing discoverability broader than choose-tenant selection', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create(['slug' => 'landing-vs-selector']);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$activeTenant = Tenant::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Landing Active Tenant',
|
||||||
|
]);
|
||||||
|
$draftTenant = Tenant::factory()->draft()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Landing Draft Tenant',
|
||||||
|
]);
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Landing Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
$archivedTenant = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Landing Archived Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$activeTenant->getKey() => ['role' => 'owner'],
|
||||||
|
$draftTenant->getKey() => ['role' => 'owner'],
|
||||||
|
$onboardingTenant->getKey() => ['role' => 'owner'],
|
||||||
|
$archivedTenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Landing Active Tenant')
|
||||||
|
->assertSee('Landing Draft Tenant')
|
||||||
|
->assertSee('Landing Onboarding Tenant')
|
||||||
|
->assertSee('Landing Archived Tenant');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get('/admin/choose-tenant')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Landing Active Tenant')
|
||||||
|
->assertDontSee('Landing Draft Tenant')
|
||||||
|
->assertDontSee('Landing Onboarding Tenant')
|
||||||
|
->assertDontSee('Landing Archived Tenant');
|
||||||
|
});
|
||||||
|
|||||||
@ -15,7 +15,7 @@ function tenantSearchTitles($results): array
|
|||||||
return collect($results)->map(fn ($result): string => (string) $result->title)->all();
|
return collect($results)->map(fn ($result): string => (string) $result->title)->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
it('limits tenant global-search results to selectable active tenants in the current workspace', function (): void {
|
it('keeps tenant global-search aligned with administrative discoverability in the current workspace', function (): void {
|
||||||
$active = Tenant::factory()->active()->create(['name' => 'Lifecycle Active']);
|
$active = Tenant::factory()->active()->create(['name' => 'Lifecycle Active']);
|
||||||
[$user, $active] = createUserWithTenant(tenant: $active, role: 'owner');
|
[$user, $active] = createUserWithTenant(tenant: $active, role: 'owner');
|
||||||
|
|
||||||
@ -45,10 +45,13 @@ function tenantSearchTitles($results): array
|
|||||||
|
|
||||||
$results = TenantResource::getGlobalSearchResults('Lifecycle');
|
$results = TenantResource::getGlobalSearchResults('Lifecycle');
|
||||||
|
|
||||||
expect(tenantSearchTitles($results))->toBe([
|
expect(tenantSearchTitles($results))->toEqualCanonicalizing([
|
||||||
'Lifecycle Active',
|
'Lifecycle Active',
|
||||||
|
'Lifecycle Onboarding',
|
||||||
|
'Lifecycle Draft',
|
||||||
|
'Lifecycle Archived',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($results->first()?->url)
|
expect($results->first()?->url)
|
||||||
->toBe(TenantResource::getUrl('view', ['record' => $active], panel: 'admin'));
|
->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -144,7 +144,7 @@
|
|||||||
->assertDontSee($tenantB->getFilamentName());
|
->assertDontSee($tenantB->getFilamentName());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows all workspace tenants in the header tenant picker for workspace owners', function (): void {
|
it('keeps the header tenant picker limited to tenant-entitled active tenants for workspace owners', function (): void {
|
||||||
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'owner');
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'owner');
|
||||||
|
|
||||||
@ -163,7 +163,7 @@
|
|||||||
->get('/admin/operations')
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee($tenantA->getFilamentName())
|
->assertSee($tenantA->getFilamentName())
|
||||||
->assertSee($tenantB->getFilamentName());
|
->assertDontSee($tenantB->getFilamentName());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides onboarding tenants from the header tenant picker even for workspace owners', function (): void {
|
it('hides onboarding tenants from the header tenant picker even for workspace owners', function (): void {
|
||||||
|
|||||||
@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -101,6 +107,39 @@
|
|||||||
expect($draft->fresh()->isCancelled())->toBeTrue();
|
expect($draft->fresh()->isCancelled())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves the cancel draft header action as enabled for managers with cancel capability', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'manager',
|
||||||
|
workspaceRole: 'manager',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $draft->getKey(),
|
||||||
|
])
|
||||||
|
->assertActionVisible('cancel_onboarding_draft')
|
||||||
|
->assertActionEnabled('cancel_onboarding_draft');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 404 for non-members when requesting a shared onboarding draft', function (): void {
|
it('returns 404 for non-members when requesting a shared onboarding draft', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$tenant = Tenant::factory()->create([
|
$tenant = Tenant::factory()->create([
|
||||||
@ -270,3 +309,163 @@
|
|||||||
->assertDontSee('Cancel draft')
|
->assertDontSee('Cancel draft')
|
||||||
->assertSee('View tenant');
|
->assertSee('View tenant');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps complete onboarding disabled for managers without activation capability', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'manager',
|
||||||
|
workspaceRole: 'manager',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'entra_tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'consent',
|
||||||
|
'title' => 'Required application permissions',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'low',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Consent is ready.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'bootstrap',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $draft->getKey(),
|
||||||
|
])
|
||||||
|
->assertSee('Complete onboarding')
|
||||||
|
->call('completeOnboarding')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows owners to complete activation-ready onboarding drafts', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'entra_tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'consent',
|
||||||
|
'title' => 'Required application permissions',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'low',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Consent is ready.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'bootstrap',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $draft->getKey(),
|
||||||
|
])
|
||||||
|
->assertSee('Complete onboarding')
|
||||||
|
->call('completeOnboarding')
|
||||||
|
->assertRedirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
|
|
||||||
|
$tenant->refresh();
|
||||||
|
|
||||||
|
expect($tenant->status)->toBe(Tenant::STATUS_ACTIVE);
|
||||||
|
});
|
||||||
|
|||||||
@ -111,6 +111,31 @@
|
|||||||
->assertActionVisible('view_linked_tenant');
|
->assertActionVisible('view_linked_tenant');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the linked tenant action visible for entitled operators on archived tenant summaries', function (): void {
|
||||||
|
$tenant = Tenant::factory()->archived()->create([
|
||||||
|
'name' => 'Archived Summary Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $draft->getKey(),
|
||||||
|
])
|
||||||
|
->assertActionVisible('view_linked_tenant')
|
||||||
|
->assertActionHasLabel('view_linked_tenant', 'View tenant (Archived)');
|
||||||
|
});
|
||||||
|
|
||||||
it('cancels a resumable onboarding draft through the confirmed header action', function (): void {
|
it('cancels a resumable onboarding draft through the confirmed header action', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@ -218,6 +218,49 @@
|
|||||||
->assertSee('No tenant context is currently selected.');
|
->assertSee('No tenant context is currently selected.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a canonical run viewer accessible when the run tenant is selector-ineligible but the remembered context is valid', function (): void {
|
||||||
|
$rememberedTenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Viewer Active Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
||||||
|
|
||||||
|
$runTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||||
|
'name' => 'Viewer Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $runTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $runTenant->getKey(),
|
||||||
|
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $rememberedTenant->workspace_id,
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $rememberedTenant->workspace_id => (int) $rememberedTenant->getKey(),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Current tenant context differs from this run')
|
||||||
|
->assertSee('Run tenant: '.$runTenant->name.'.')
|
||||||
|
->assertSee('Back to Operations');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders stored target scope and failure details for a completed run', function (): void {
|
it('renders stored target scope and failure details for a completed run', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@ -105,7 +105,7 @@
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertDontSee('Back to Operations');
|
->assertDontSee('Back to Operations');
|
||||||
|
|
||||||
expect(substr_count((string) $response->getContent(), '← Back to '.$tenant->name))->toBe(1);
|
expect(substr_count(html_entity_decode((string) $response->getContent(), ENT_QUOTES | ENT_HTML5), '← Back to '.$tenant->name))->toBe(1);
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void {
|
it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void {
|
||||||
@ -293,6 +293,52 @@
|
|||||||
expect($resolved?->is($currentTenant))->toBeTrue();
|
expect($resolved?->is($currentTenant))->toBeTrue();
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
|
it('keeps an administratively discoverable current tenant context on canonical run routes even when it is selector-ineligible', function (): void {
|
||||||
|
$runTenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Canonical Run Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
|
||||||
|
|
||||||
|
$currentTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $runTenant->workspace_id,
|
||||||
|
'name' => 'Current Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $currentTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $runTenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $runTenant->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($currentTenant, true);
|
||||||
|
|
||||||
|
$workspaceId = (int) $runTenant->workspace_id;
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||||
|
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||||
|
(string) $workspaceId => (int) $runTenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||||
|
$request->setLaravelSession(app('session.store'));
|
||||||
|
|
||||||
|
$route = app('router')->getRoutes()->match($request);
|
||||||
|
$request->setRouteResolver(static fn () => $route);
|
||||||
|
|
||||||
|
$resolved = app(OperateHubShell::class)->activeEntitledTenant($request);
|
||||||
|
|
||||||
|
expect($resolved?->is($currentTenant))->toBeTrue();
|
||||||
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void {
|
it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ function adminGlobalSearchTitles($results): array
|
|||||||
return collect($results)->map(fn ($result): string => (string) $result->title)->all();
|
return collect($results)->map(fn ($result): string => (string) $result->title)->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
it('does not leak non-selectable tenant results from remembered tenant context', function (): void {
|
it('does not collapse administratively discoverable tenant results because remembered tenant context is selector-ineligible', function (): void {
|
||||||
$activeTenant = Tenant::factory()->active()->create(['name' => 'Search Safety Active']);
|
$activeTenant = Tenant::factory()->active()->create(['name' => 'Search Safety Active']);
|
||||||
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||||
|
|
||||||
@ -39,7 +39,10 @@ function adminGlobalSearchTitles($results): array
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(adminGlobalSearchTitles(TenantResource::getGlobalSearchResults('Search Safety')))
|
expect(adminGlobalSearchTitles(TenantResource::getGlobalSearchResults('Search Safety')))
|
||||||
->toBe(['Search Safety Active']);
|
->toEqualCanonicalizing([
|
||||||
|
'Search Safety Active',
|
||||||
|
'Search Safety Onboarding',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps operation runs out of admin global search regardless of remembered context state', function (): void {
|
it('keeps operation runs out of admin global search regardless of remembered context state', function (): void {
|
||||||
|
|||||||
@ -147,7 +147,7 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
->assertActionHidden('archive');
|
->assertActionHidden('archive');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps tenant global search aligned with active-only selector semantics across lifecycles', function (): void {
|
it('keeps tenant global search aligned with administrative discoverability across lifecycles', function (): void {
|
||||||
$active = Tenant::factory()->active()->create(['name' => 'Surface Search Active']);
|
$active = Tenant::factory()->active()->create(['name' => 'Surface Search Active']);
|
||||||
[$user, $active] = createUserWithTenant(tenant: $active, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
[$user, $active] = createUserWithTenant(tenant: $active, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
@ -177,7 +177,12 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $active->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $active->workspace_id);
|
||||||
|
|
||||||
expect(tenantActionSurfaceSearchTitles(TenantResource::getGlobalSearchResults('Surface Search')))
|
expect(tenantActionSurfaceSearchTitles(TenantResource::getGlobalSearchResults('Surface Search')))
|
||||||
->toBe(['Surface Search Active']);
|
->toEqualCanonicalizing([
|
||||||
|
'Surface Search Active',
|
||||||
|
'Surface Search Draft',
|
||||||
|
'Surface Search Onboarding',
|
||||||
|
'Surface Search Archived',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps list-row lifecycle actions independent from the selected header tenant context', function (): void {
|
it('keeps list-row lifecycle actions independent from the selected header tenant context', function (): void {
|
||||||
|
|||||||
@ -96,6 +96,40 @@
|
|||||||
->assertActionHidden('related_onboarding');
|
->assertActionHidden('related_onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows verification only for active tenants on administrative list and detail surfaces', function (
|
||||||
|
\Closure $tenantFactory,
|
||||||
|
bool $shouldBeVisible,
|
||||||
|
): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$list = Livewire::actingAs($user)->test(ListTenants::class);
|
||||||
|
|
||||||
|
if ($shouldBeVisible) {
|
||||||
|
$list->assertTableActionVisible('verify', $tenant);
|
||||||
|
} else {
|
||||||
|
$list->assertTableActionHidden('verify', $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$view = Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]);
|
||||||
|
|
||||||
|
if ($shouldBeVisible) {
|
||||||
|
$view->assertActionVisible('verify');
|
||||||
|
} else {
|
||||||
|
$view->assertActionHidden('verify');
|
||||||
|
}
|
||||||
|
})->with([
|
||||||
|
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create(), false],
|
||||||
|
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create(), false],
|
||||||
|
'active' => [fn (): Tenant => Tenant::factory()->active()->create(), true],
|
||||||
|
'archived' => [fn (): Tenant => Tenant::factory()->archived()->create(), false],
|
||||||
|
]);
|
||||||
|
|
||||||
it('keeps lifecycle actions visible but disabled for in-scope members without mutation capability', function (): void {
|
it('keeps lifecycle actions visible but disabled for in-scope members without mutation capability', function (): void {
|
||||||
$tenant = Tenant::factory()->active()->create();
|
$tenant = Tenant::factory()->active()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
||||||
@ -108,6 +142,58 @@
|
|||||||
->assertTableActionDisabled('archive', $tenant);
|
->assertTableActionDisabled('archive', $tenant);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows capability-denied actions as disabled but keeps lifecycle-denied actions hidden', function (): void {
|
||||||
|
$activeTenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Capability Denied Active Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'manager', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||||
|
'name' => 'Lifecycle Denied Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $onboardingTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'manager',
|
||||||
|
workspaceRole: 'manager',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $onboardingTenant->workspace,
|
||||||
|
'tenant' => $onboardingTenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $onboardingTenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $onboardingTenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $activeTenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('archive', $activeTenant)
|
||||||
|
->assertTableActionDisabled('archive', $activeTenant)
|
||||||
|
->assertTableActionHidden('archive', $onboardingTenant)
|
||||||
|
->assertTableActionVisible('related_onboarding', $onboardingTenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $activeTenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionDisabled('archive');
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $onboardingTenant->getRouteKey()])
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionVisible('related_onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
||||||
$tenant = $tenantFactory();
|
$tenant = $tenantFactory();
|
||||||
[$user] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
[$user] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|||||||
@ -3,9 +3,13 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
describe('Tenant resource authorization', function () {
|
describe('Tenant resource authorization', function () {
|
||||||
it('cannot be created by non-members', function () {
|
it('cannot be created by non-members', function () {
|
||||||
@ -102,6 +106,34 @@
|
|||||||
->and(TenantResource::canDelete($archivedTenant))->toBeFalse();
|
->and(TenantResource::canDelete($archivedTenant))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps mutation capability checks separate from lifecycle-specific action visibility', function (): void {
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create();
|
||||||
|
[$user, $onboardingTenant] = createUserWithTenant(
|
||||||
|
tenant: $onboardingTenant,
|
||||||
|
role: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$archivedTenant = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $onboardingTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $archivedTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$archivedTenant = Tenant::withTrashed()->findOrFail((int) $archivedTenant->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
expect(TenantResource::canDelete($onboardingTenant))->toBeTrue()
|
||||||
|
->and(TenantResource::canDelete($archivedTenant))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the tenant resource index usable with no selected tenant context', function () {
|
it('keeps the tenant resource index usable with no selected tenant context', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
@ -111,4 +143,46 @@
|
|||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee((string) $tenant->name);
|
->assertSee((string) $tenant->name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves archive and verify actions as enabled for owners on active tenant surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('archive', $tenant)
|
||||||
|
->assertTableActionEnabled('archive', $tenant)
|
||||||
|
->assertTableActionVisible('verify', $tenant)
|
||||||
|
->assertTableActionEnabled('verify', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionEnabled('archive')
|
||||||
|
->assertActionVisible('verify')
|
||||||
|
->assertActionEnabled('verify');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps lifecycle mutation actions visible but disabled for managers without delete capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('archive', $tenant)
|
||||||
|
->assertTableActionDisabled('archive', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionDisabled('archive');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -98,3 +98,27 @@
|
|||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Archived');
|
->assertSee('Archived');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps onboarding admin tenant routes authoritative when another tenant is currently selected', function (): void {
|
||||||
|
[$user, $selectedTenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $selectedTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $onboardingTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Filament::setTenant($selectedTenant, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $selectedTenant->workspace_id])
|
||||||
|
->get(TenantResource::getUrl('view', ['record' => $onboardingTenant]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee($onboardingTenant->name);
|
||||||
|
});
|
||||||
|
|||||||
@ -57,3 +57,19 @@
|
|||||||
->get("/admin/t/{$otherTenant->external_id}")
|
->get("/admin/t/{$otherTenant->external_id}")
|
||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps non-member onboarding tenant admin routes as 404 even when another tenant is selected', function (): void {
|
||||||
|
[$user, $selectedTenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$otherTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $selectedTenant->workspace_id,
|
||||||
|
'external_id' => 'hidden-onboarding-tenant-b',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant($selectedTenant, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $selectedTenant->workspace_id])
|
||||||
|
->get("/admin/tenants/{$otherTenant->external_id}")
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|||||||
@ -96,7 +96,7 @@
|
|||||||
->assertStatus(404);
|
->assertStatus(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns no tenant global-search results when the user lacks any active tenant membership', function (): void {
|
it('keeps tenant global-search broader than selector eligibility when the user only has administratively discoverable memberships', function (): void {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$draft = Tenant::factory()->draft()->create(['name' => 'Search Draft']);
|
$draft = Tenant::factory()->draft()->create(['name' => 'Search Draft']);
|
||||||
@ -123,7 +123,13 @@
|
|||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $draft->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $draft->workspace_id);
|
||||||
|
|
||||||
expect(TenantResource::getGlobalSearchResults('Search'))->toHaveCount(0);
|
expect(TenantResource::getGlobalSearchResults('Search'))
|
||||||
|
->toHaveCount(3)
|
||||||
|
->sequence(
|
||||||
|
fn ($result) => $result->title->toContain('Search'),
|
||||||
|
fn ($result) => $result->title->toContain('Search'),
|
||||||
|
fn ($result) => $result->title->toContain('Search'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render onboarding or archived tenants in the header selector on workspace pages', function (): void {
|
it('does not render onboarding or archived tenants in the header selector on workspace pages', function (): void {
|
||||||
|
|||||||
@ -63,6 +63,41 @@
|
|||||||
->assertSee('View managed tenants');
|
->assertSee('View managed tenants');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps selector eligibility narrower than managed-tenant administrative discoverability', function (): void {
|
||||||
|
$activeTenant = Tenant::factory()->active()->create(['name' => 'Selector Active Tenant']);
|
||||||
|
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||||
|
'name' => 'Selector Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
$archivedTenant = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||||
|
'name' => 'Selector Archived Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(tenant: $onboardingTenant, user: $user, role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
createUserWithTenant(tenant: $archivedTenant, user: $user, role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||||
|
->get('/admin/choose-tenant')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Selector Active Tenant')
|
||||||
|
->assertDontSee('Selector Onboarding Tenant')
|
||||||
|
->assertDontSee('Selector Archived Tenant');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||||
|
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $activeTenant->workspace]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Selector Active Tenant')
|
||||||
|
->assertSee('Selector Onboarding Tenant')
|
||||||
|
->assertSee('Selector Archived Tenant');
|
||||||
|
});
|
||||||
|
|
||||||
it('redirects clear selected tenant from tenant-bound pages back to a workspace-safe managed-tenants page', function (): void {
|
it('redirects clear selected tenant from tenant-bound pages back to a workspace-safe managed-tenants page', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
89
tests/Feature/Workspaces/SelectTenantControllerTest.php
Normal file
89
tests/Feature/Workspaces/SelectTenantControllerTest.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('stores remembered context and redirects to the tenant dashboard for active tenants', function (): void {
|
||||||
|
$activeTenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||||
|
->post(route('admin.select-tenant'), [
|
||||||
|
'tenant_id' => (int) $activeTenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||||
|
|
||||||
|
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||||
|
->toHaveKey((string) $activeTenant->workspace_id, (int) $activeTenant->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when selecting an onboarding tenant that is not eligible for the standard lane', function (): void {
|
||||||
|
$activeTenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $onboardingTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||||
|
->post(route('admin.select-tenant'), [
|
||||||
|
'tenant_id' => (int) $onboardingTenant->getKey(),
|
||||||
|
])
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||||
|
->not->toHaveKey((string) $activeTenant->workspace_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to choose-workspace when no workspace is selected', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspaceA = Workspace::factory()->create();
|
||||||
|
$workspaceB = Workspace::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceA->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceB->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $workspaceA->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('admin.select-tenant'), [
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
])
|
||||||
|
->assertRedirect('/admin/choose-workspace');
|
||||||
|
});
|
||||||
45
tests/Unit/Tenants/TenantOperabilityOutcomeTest.php
Normal file
45
tests/Unit/Tenants/TenantOperabilityOutcomeTest.php
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantLifecycle;
|
||||||
|
use App\Support\Tenants\TenantOperabilityOutcome;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||||
|
|
||||||
|
it('builds allowed outcomes with stable defaults', function (): void {
|
||||||
|
$outcome = TenantOperabilityOutcome::allow(
|
||||||
|
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
|
lifecycle: TenantLifecycle::Archived,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
metadata: ['selected_tenant_id' => 123],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($outcome->allowed)->toBeTrue()
|
||||||
|
->and($outcome->discoverable)->toBeTrue()
|
||||||
|
->and($outcome->reasonCode)->toBeNull()
|
||||||
|
->and($outcome->metadata)->toBe(['selected_tenant_id' => 123]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes capability and lifecycle denials', function (): void {
|
||||||
|
$capabilityOutcome = TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
lifecycle: TenantLifecycle::Active,
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::MissingCapability,
|
||||||
|
requiredCapability: 'tenant.manage',
|
||||||
|
);
|
||||||
|
|
||||||
|
$lifecycleOutcome = TenantOperabilityOutcome::deny(
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
lifecycle: TenantLifecycle::Onboarding,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
reasonCode: TenantOperabilityReasonCode::SelectorIneligibleLifecycle,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($capabilityOutcome->isDeniedForCapability())->toBeTrue()
|
||||||
|
->and($capabilityOutcome->isDeniedForLifecycle())->toBeFalse()
|
||||||
|
->and($lifecycleOutcome->isDeniedForCapability())->toBeFalse()
|
||||||
|
->and($lifecycleOutcome->isDeniedForLifecycle())->toBeTrue();
|
||||||
|
});
|
||||||
@ -4,7 +4,10 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecycle;
|
use App\Support\Tenants\TenantLifecycle;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -68,6 +71,67 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
it('returns structured selector outcomes for active and non-active tenants', function (
|
||||||
|
\Closure $tenantFactory,
|
||||||
|
bool $expectedAllowed,
|
||||||
|
?TenantOperabilityReasonCode $expectedReason,
|
||||||
|
): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
|
||||||
|
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($outcome->allowed)->toBe($expectedAllowed)
|
||||||
|
->and($outcome->reasonCode)->toBe($expectedReason);
|
||||||
|
})->with([
|
||||||
|
'active-selector-eligible' => [fn (): Tenant => Tenant::factory()->active()->create(), true, null],
|
||||||
|
'draft-selector-ineligible' => [fn (): Tenant => Tenant::factory()->draft()->create(), false, TenantOperabilityReasonCode::SelectorIneligibleLifecycle],
|
||||||
|
'onboarding-selector-ineligible' => [fn (): Tenant => Tenant::factory()->onboarding()->create(), false, TenantOperabilityReasonCode::SelectorIneligibleLifecycle],
|
||||||
|
'archived-selector-ineligible' => [fn (): Tenant => Tenant::factory()->archived()->create(), false, TenantOperabilityReasonCode::SelectorIneligibleLifecycle],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('keeps tenant-bound and canonical lanes viewable across all tenant lifecycles', function (
|
||||||
|
\Closure $tenantFactory,
|
||||||
|
TenantInteractionLane $lane,
|
||||||
|
): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
|
||||||
|
$question = $lane === TenantInteractionLane::AdministrativeManagement
|
||||||
|
? TenantOperabilityQuestion::TenantBoundViewability
|
||||||
|
: TenantOperabilityQuestion::CanonicalLinkedRecordViewability;
|
||||||
|
|
||||||
|
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: $question,
|
||||||
|
lane: $lane,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($outcome->allowed)->toBeTrue()
|
||||||
|
->and($outcome->discoverable)->toBeTrue();
|
||||||
|
})->with([
|
||||||
|
'draft-admin' => [fn (): Tenant => Tenant::factory()->draft()->create(), TenantInteractionLane::AdministrativeManagement],
|
||||||
|
'onboarding-admin' => [fn (): Tenant => Tenant::factory()->onboarding()->create(), TenantInteractionLane::AdministrativeManagement],
|
||||||
|
'archived-admin' => [fn (): Tenant => Tenant::factory()->archived()->create(), TenantInteractionLane::AdministrativeManagement],
|
||||||
|
'onboarding-canonical' => [fn (): Tenant => Tenant::factory()->onboarding()->create(), TenantInteractionLane::CanonicalWorkspaceRecord],
|
||||||
|
'archived-canonical' => [fn (): Tenant => Tenant::factory()->archived()->create(), TenantInteractionLane::CanonicalWorkspaceRecord],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('returns wrong-lane reasons for questions asked in the wrong lane', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
|
||||||
|
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::ArchiveEligibility,
|
||||||
|
lane: TenantInteractionLane::CanonicalWorkspaceRecord,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($outcome->allowed)->toBeFalse()
|
||||||
|
->and($outcome->reasonCode)->toBe(TenantOperabilityReasonCode::WrongLane);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns lifecycle-safe primary management action keys', function (
|
it('returns lifecycle-safe primary management action keys', function (
|
||||||
\Closure $tenantFactory,
|
\Closure $tenantFactory,
|
||||||
?string $expectedActionKey,
|
?string $expectedActionKey,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user