feat: implement tenant lifecycle operability semantics
This commit is contained in:
parent
3f6f80f7af
commit
74cb9e36b1
76
.codex/prompts/tenantpilot.audit.md
Normal file
76
.codex/prompts/tenantpilot.audit.md
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||
|
||||
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||
|
||||
## Audit focus
|
||||
|
||||
Prioritize:
|
||||
|
||||
- workspace and tenant isolation
|
||||
- route model binding safety
|
||||
- Filament resources, pages, relation managers, widgets, and actions
|
||||
- Livewire public properties and serialized state risks
|
||||
- jobs, queue boundaries, and backend authorization rechecks
|
||||
- provider access boundaries
|
||||
- `OperationRun` consistency
|
||||
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||
- audit trail completeness
|
||||
- wrong-tenant regression coverage
|
||||
- unauthorized action coverage
|
||||
- workflow misuse and invalid transition coverage
|
||||
|
||||
## Output rules
|
||||
|
||||
Classify every finding as exactly one of:
|
||||
|
||||
- Constitutional Violation
|
||||
- Architectural Drift
|
||||
- Workflow Trust Gap
|
||||
- Test Blind Spot
|
||||
|
||||
Assign one severity:
|
||||
|
||||
- Severity 1: Critical
|
||||
- Severity 2: High
|
||||
- Severity 3: Medium
|
||||
- Severity 4: Low
|
||||
|
||||
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||
|
||||
For each finding provide:
|
||||
|
||||
1. Title
|
||||
2. Classification
|
||||
3. Severity
|
||||
4. Affected Area
|
||||
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||
6. Why this matters in TenantPilot
|
||||
7. Recommended structural correction
|
||||
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not praise the codebase.
|
||||
- Do not focus on style unless it affects architecture or safety.
|
||||
- Do not suggest random patterns without proving fit.
|
||||
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||
|
||||
## Repository context
|
||||
|
||||
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||
|
||||
The strategic priorities are:
|
||||
|
||||
- workspace-first context modeling
|
||||
- capability-first RBAC
|
||||
- strong auditability
|
||||
- deterministic workflow semantics
|
||||
- provider access through canonical boundaries
|
||||
- minimal duplication of domain logic across UI surfaces
|
||||
|
||||
Return the audit as a concise but substantive findings report.
|
||||
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -75,6 +75,8 @@ ## Active Technologies
|
||||
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
|
||||
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141 (142-rbac-role-definition-diff-ux-upgrade)
|
||||
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
|
||||
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -94,8 +96,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 143-tenant-lifecycle-operability-context-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4
|
||||
- 142-rbac-role-definition-diff-ux-upgrade: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141
|
||||
- 141-shared-diff-presentation-foundation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||
---
|
||||
|
||||
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||
|
||||
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||
|
||||
## Audit focus
|
||||
|
||||
Prioritize:
|
||||
|
||||
- workspace and tenant isolation
|
||||
- route model binding safety
|
||||
- Filament resources, pages, relation managers, widgets, and actions
|
||||
- Livewire public properties and serialized state risks
|
||||
- jobs, queue boundaries, and backend authorization rechecks
|
||||
- provider access boundaries
|
||||
- `OperationRun` consistency
|
||||
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||
- audit trail completeness
|
||||
- wrong-tenant regression coverage
|
||||
- unauthorized action coverage
|
||||
- workflow misuse and invalid transition coverage
|
||||
|
||||
## Output rules
|
||||
|
||||
Classify every finding as exactly one of:
|
||||
|
||||
- Constitutional Violation
|
||||
- Architectural Drift
|
||||
- Workflow Trust Gap
|
||||
- Test Blind Spot
|
||||
|
||||
Assign one severity:
|
||||
|
||||
- Severity 1: Critical
|
||||
- Severity 2: High
|
||||
- Severity 3: Medium
|
||||
- Severity 4: Low
|
||||
|
||||
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||
|
||||
For each finding provide:
|
||||
|
||||
1. Title
|
||||
2. Classification
|
||||
3. Severity
|
||||
4. Affected Area
|
||||
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||
6. Why this matters in TenantPilot
|
||||
7. Recommended structural correction
|
||||
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not praise the codebase.
|
||||
- Do not focus on style unless it affects architecture or safety.
|
||||
- Do not suggest random patterns without proving fit.
|
||||
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||
|
||||
## Repository context
|
||||
|
||||
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||
|
||||
The strategic priorities are:
|
||||
|
||||
- workspace-first context modeling
|
||||
- capability-first RBAC
|
||||
- strong auditability
|
||||
- deterministic workflow semantics
|
||||
- provider access through canonical boundaries
|
||||
- minimal duplication of domain logic across UI surfaces
|
||||
|
||||
Return the audit as a concise but substantive findings report.
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
@ -52,10 +53,10 @@ public function getTenants(): Collection
|
||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
||||
|
||||
if ($tenants instanceof Collection) {
|
||||
return $tenants;
|
||||
return app(TenantOperabilityService::class)->filterSelectable($tenants);
|
||||
}
|
||||
|
||||
return collect($tenants);
|
||||
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
|
||||
}
|
||||
|
||||
public function selectTenant(int $tenantId): void
|
||||
@ -67,7 +68,6 @@ public function selectTenant(int $tenantId): void
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
|
||||
@ -79,9 +79,13 @@ public function selectTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! app(TenantOperabilityService::class)->canSelectAsContext($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
|
||||
@ -139,8 +139,11 @@ public function table(Table $table): Table
|
||||
return OperationRunResource::table($table)
|
||||
->query(function (): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
@ -154,8 +157,8 @@ public function table(Table $table): Table
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
);
|
||||
|
||||
return $this->applyActiveTab($query);
|
||||
|
||||
@ -56,6 +56,8 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_run_detail')
|
||||
@ -64,14 +66,12 @@ protected function getHeaderActions(): array
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
} elseif ($activeTenant instanceof Tenant) {
|
||||
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||
->label('← Back to '.$activeTenant->name)
|
||||
->color('gray')
|
||||
@ -142,15 +142,6 @@ public function mount(OperationRun $run): void
|
||||
|
||||
$this->authorize('view', $run);
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (
|
||||
$activeTenant instanceof Tenant
|
||||
&& (int) ($run->tenant_id ?? 0) !== (int) $activeTenant->getKey()
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Exceptions\Onboarding\OnboardingDraftConflictException;
|
||||
use App\Exceptions\Onboarding\OnboardingDraftImmutableException;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
@ -32,6 +33,7 @@
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Services\Providers\ProviderOperationRegistry;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Services\Verification\VerificationCheckAcknowledgementService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -154,7 +156,14 @@ protected function getHeaderActions(): array
|
||||
->url(route('admin.onboarding'));
|
||||
}
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession && $this->onboardingSession->isResumable()) {
|
||||
if ($this->canViewLinkedTenant()) {
|
||||
$actions[] = Action::make('view_linked_tenant')
|
||||
->label('View tenant')
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $this->managedTenant]));
|
||||
}
|
||||
|
||||
if ($this->canResumeDraft($this->onboardingSession)) {
|
||||
$actions[] = Action::make('cancel_onboarding_draft')
|
||||
->label('Cancel draft')
|
||||
->color('danger')
|
||||
@ -168,6 +177,21 @@ protected function getHeaderActions(): array
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function canViewLinkedTenant(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $this->managedTenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($this->managedTenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(TenantOperabilityService::class)->canViewTenantSurface($this->managedTenant);
|
||||
}
|
||||
|
||||
public function mount(TenantOnboardingSession|int|string|null $onboardingDraft = null): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -782,7 +806,7 @@ private function nonResumableSummarySchema(): array
|
||||
|
||||
return [
|
||||
Callout::make("This onboarding draft is {$statusLabel}.")
|
||||
->description('Completed and cancelled drafts remain viewable, but they cannot return to editable wizard mode.')
|
||||
->description('Completed, cancelled, and lifecycle-locked drafts remain viewable, but they cannot return to editable wizard mode.')
|
||||
->warning(),
|
||||
...$this->resumeContextSchema(),
|
||||
Section::make('Draft summary')
|
||||
@ -871,7 +895,7 @@ private function cancelOnboardingDraft(): void
|
||||
|
||||
$this->authorize('cancel', $this->onboardingSession);
|
||||
|
||||
if (! $this->onboardingSession->isResumable()) {
|
||||
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||
Notification::make()
|
||||
->title('Draft is not resumable')
|
||||
->warning()
|
||||
@ -917,6 +941,25 @@ private function cancelOnboardingDraft(): void
|
||||
resourceId: (string) $this->onboardingSession->getKey(),
|
||||
);
|
||||
|
||||
$normalizedTenant = $this->lifecycleService()->syncLinkedTenantAfterCancellation($this->onboardingSession);
|
||||
|
||||
if ($normalizedTenant instanceof Tenant) {
|
||||
app(WorkspaceAuditLogger::class)->logTenantLifecycleAction(
|
||||
tenant: $normalizedTenant,
|
||||
action: AuditActionId::TenantReturnedToDraft,
|
||||
actor: $user,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source' => 'onboarding_cancel',
|
||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->managedTenant = $normalizedTenant;
|
||||
$this->onboardingSession->setRelation('tenant', $normalizedTenant);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Onboarding draft cancelled')
|
||||
->success()
|
||||
@ -928,7 +971,7 @@ private function cancelOnboardingDraft(): void
|
||||
private function showsNonResumableSummary(): bool
|
||||
{
|
||||
return $this->onboardingSession instanceof TenantOnboardingSession
|
||||
&& ! $this->onboardingSession->isResumable();
|
||||
&& ! $this->canResumeDraft($this->onboardingSession);
|
||||
}
|
||||
|
||||
private function shouldShowDraftLandingAction(): bool
|
||||
@ -937,7 +980,7 @@ private function shouldShowDraftLandingAction(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->onboardingSession->isResumable()) {
|
||||
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1769,11 +1812,17 @@ private function authorizeEditableDraft(User $user): void
|
||||
|
||||
$this->authorize('update', $this->onboardingSession);
|
||||
|
||||
if (! $this->onboardingSession->isResumable()) {
|
||||
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
private function canResumeDraft(?TenantOnboardingSession $draft): bool
|
||||
{
|
||||
return $draft instanceof TenantOnboardingSession
|
||||
&& $this->lifecycleService()->canResumeDraft($draft);
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceMember(User $user): void
|
||||
{
|
||||
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
@ -54,11 +55,17 @@ public function getTenants(): Collection
|
||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
return $user->tenants()
|
||||
$tenantIds = $user->tenantMemberships()
|
||||
->pluck('tenant_id');
|
||||
|
||||
return Tenant::query()
|
||||
->withTrashed()
|
||||
->whereIn('id', $tenantIds)
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
->get()
|
||||
->filter(fn (Tenant $tenant): bool => app(TenantOperabilityService::class)->canViewTenantSurface($tenant))
|
||||
->values();
|
||||
}
|
||||
|
||||
public function goToChooseTenant(): void
|
||||
@ -75,7 +82,7 @@ public function openTenant(int $tenantId): void
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->withTrashed()
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
@ -88,6 +95,6 @@ public function openTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,14 +157,6 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(function (): array {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return [
|
||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||
];
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -196,7 +188,6 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
->options(function (): array {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return [];
|
||||
@ -204,10 +195,6 @@ public static function table(Table $table): Table
|
||||
|
||||
$types = OperationRun::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when(
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
)
|
||||
->select('type')
|
||||
->distinct()
|
||||
->orderBy('type')
|
||||
@ -233,14 +220,8 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
}
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
$tenantId = $activeTenant instanceof Tenant
|
||||
? (int) $activeTenant->getKey()
|
||||
: null;
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
|
||||
->whereNotNull('initiator_name')
|
||||
->select('initiator_name')
|
||||
->distinct()
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
use App\Models\EntraRoleDefinition;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
@ -22,7 +24,9 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
@ -57,6 +61,7 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
@ -208,6 +213,14 @@ public static function getEloquentQuery(): Builder
|
||||
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
||||
}
|
||||
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
return static::tenantOperability()->applySelectableScope(
|
||||
static::getEloquentQuery(),
|
||||
(new Tenant)->getTable(),
|
||||
);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
@ -290,6 +303,14 @@ public static function table(Table $table): Table
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
||||
Actions\Action::make('related_onboarding')
|
||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record) ?? 'View related onboarding')
|
||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraft($record)?->isResumable() === true
|
||||
&& static::tenantOperability()->canResumeOnboarding($record)
|
||||
? 'heroicon-o-arrow-path'
|
||||
: 'heroicon-o-eye')
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraft($record) instanceof TenantOnboardingSession),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
@ -421,10 +442,10 @@ public static function table(Table $table): Table
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle('Tenant reactivated')
|
||||
->successNotificationTitle('Tenant restored')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -440,14 +461,18 @@ public static function table(Table $table): Table
|
||||
|
||||
$record->restore();
|
||||
|
||||
$auditLogger->log(
|
||||
$auditLogger->logTenantLifecycleAction(
|
||||
tenant: $record,
|
||||
action: 'tenant.restored',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
action: AuditActionId::TenantRestored,
|
||||
actor: $user,
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant restored')
|
||||
->body('The tenant is available again in administrative and operating flows.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
@ -595,12 +620,12 @@ public static function table(Table $table): Table
|
||||
static::rbacAction(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -616,18 +641,16 @@ public static function table(Table $table): Table
|
||||
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
$auditLogger->logTenantLifecycleAction(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
action: AuditActionId::TenantArchived,
|
||||
actor: $user,
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->title('Tenant archived')
|
||||
->body('The tenant remains viewable in management and audit flows, but it is no longer selectable as active context.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
@ -1014,6 +1037,61 @@ protected static function storedPermissionSnapshot(Tenant $tenant): array
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
public static function tenantOperability(): TenantOperabilityService
|
||||
{
|
||||
return app(TenantOperabilityService::class);
|
||||
}
|
||||
|
||||
public static function relatedOnboardingDraft(Tenant $tenant): ?TenantOnboardingSession
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->first(fn (TenantOnboardingSession $draft): bool => Gate::forUser($user)->allows('view', $draft));
|
||||
}
|
||||
|
||||
public static function relatedOnboardingDraftActionLabel(Tenant $tenant): ?string
|
||||
{
|
||||
$draft = static::relatedOnboardingDraft($tenant);
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($draft->isResumable() && static::tenantOperability()->canResumeOnboarding($tenant)) {
|
||||
return 'Resume onboarding';
|
||||
}
|
||||
|
||||
if ($draft->isCancelled()) {
|
||||
return 'View cancelled onboarding';
|
||||
}
|
||||
|
||||
if ($draft->isCompleted()) {
|
||||
return 'View completed onboarding';
|
||||
}
|
||||
|
||||
return 'View related onboarding';
|
||||
}
|
||||
|
||||
public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$draft = static::relatedOnboardingDraft($tenant);
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -4,10 +4,14 @@
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTenant extends EditRecord
|
||||
@ -24,8 +28,32 @@ protected function getHeaderActions(): array
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record): void {
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->logTenantLifecycleAction(
|
||||
tenant: $record,
|
||||
action: AuditActionId::TenantArchived,
|
||||
actor: $user,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'internal_tenant_id' => (int) $record->getKey(),
|
||||
'tenant_guid' => (string) $record->tenant_id,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant archived')
|
||||
->body('The tenant remains viewable in management and audit flows, but it is no longer selectable as active context.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
|
||||
@ -10,10 +10,12 @@
|
||||
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||
use App\Jobs\RefreshTenantRbacHealthJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
@ -63,6 +65,14 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('related_onboarding')
|
||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record) ?? 'View related onboarding')
|
||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraft($record)?->isResumable() === true
|
||||
&& TenantResource::tenantOperability()->canResumeOnboarding($record)
|
||||
? 'heroicon-o-arrow-path'
|
||||
: 'heroicon-o-eye')
|
||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraft($record) instanceof TenantOnboardingSession),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
@ -264,21 +274,65 @@ protected function getHeaderActions(): array
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
$auditLogger->logTenantLifecycleAction(
|
||||
tenant: $record,
|
||||
action: AuditActionId::TenantRestored,
|
||||
actor: $user,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'internal_tenant_id' => (int) $record->getKey(),
|
||||
'tenant_guid' => (string) $record->tenant_id,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant restored')
|
||||
->body('The tenant is available again in administrative and operating flows.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
$auditLogger->logTenantLifecycleAction(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
action: AuditActionId::TenantArchived,
|
||||
actor: $user,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'internal_tenant_id' => (int) $record->getKey(),
|
||||
@ -288,8 +342,8 @@ protected function getHeaderActions(): array
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->title('Tenant archived')
|
||||
->body('The tenant remains viewable in management and audit flows, but it is no longer selectable as active context.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@ -34,7 +35,6 @@ public function __invoke(Request $request): RedirectResponse
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($validated['tenant_id'])
|
||||
->first();
|
||||
@ -47,9 +47,13 @@ public function __invoke(Request $request): RedirectResponse
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! app(TenantOperabilityService::class)->canSelectAsContext($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, $request);
|
||||
|
||||
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Tenants\TenantLifecycle;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Models\Contracts\HasName;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -114,12 +115,12 @@ public static function activeQuery(): Builder
|
||||
{
|
||||
return static::query()
|
||||
->whereNull('deleted_at')
|
||||
->where('status', self::STATUS_ACTIVE);
|
||||
->where('status', TenantLifecycle::Active->value);
|
||||
}
|
||||
|
||||
public function makeCurrent(): void
|
||||
{
|
||||
if ($this->trashed() || $this->status !== self::STATUS_ACTIVE) {
|
||||
if (! $this->isSelectableAsContext()) {
|
||||
throw new RuntimeException('Only active tenants can be made current.');
|
||||
}
|
||||
|
||||
@ -337,6 +338,36 @@ public function scopeForTenant(Builder $query, self|int|string $tenant): Builder
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return ! $this->trashed() && ($this->status ?? 'active') === 'active';
|
||||
return $this->isSelectableAsContext();
|
||||
}
|
||||
|
||||
public function lifecycle(): TenantLifecycle
|
||||
{
|
||||
return TenantLifecycle::fromTenant($this);
|
||||
}
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->lifecycle() === TenantLifecycle::Draft;
|
||||
}
|
||||
|
||||
public function isOnboarding(): bool
|
||||
{
|
||||
return $this->lifecycle() === TenantLifecycle::Onboarding;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->lifecycle() === TenantLifecycle::Archived;
|
||||
}
|
||||
|
||||
public function isSelectableAsContext(): bool
|
||||
{
|
||||
return ! $this->trashed() && $this->lifecycle()->canSelectAsContext();
|
||||
}
|
||||
|
||||
public function canResumeOnboarding(): bool
|
||||
{
|
||||
return ! $this->trashed() && $this->lifecycle()->canResumeOnboarding();
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +82,7 @@ public function workspace(): BelongsTo
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
return $this->belongsTo(Tenant::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -124,19 +124,34 @@ public function isCancelled(): bool
|
||||
|| $this->lifecycleState() === OnboardingLifecycleState::Cancelled;
|
||||
}
|
||||
|
||||
public function status(): OnboardingDraftStatus
|
||||
public function workflowStatus(): OnboardingDraftStatus
|
||||
{
|
||||
return OnboardingDraftStatus::fromLifecycleState($this->lifecycleState());
|
||||
}
|
||||
|
||||
public function status(): OnboardingDraftStatus
|
||||
{
|
||||
return $this->workflowStatus();
|
||||
}
|
||||
|
||||
public function isWorkflowResumable(): bool
|
||||
{
|
||||
return $this->workflowStatus()->isResumable();
|
||||
}
|
||||
|
||||
public function isResumable(): bool
|
||||
{
|
||||
return $this->status()->isResumable();
|
||||
return $this->isWorkflowResumable();
|
||||
}
|
||||
|
||||
public function isWorkflowTerminal(): bool
|
||||
{
|
||||
return $this->lifecycleState()->isTerminal();
|
||||
}
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return $this->lifecycleState()->isTerminal();
|
||||
return $this->isWorkflowTerminal();
|
||||
}
|
||||
|
||||
public function lifecycleState(): OnboardingLifecycleState
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
@ -156,12 +157,12 @@ public function getTenants(Panel $panel): array|Collection
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
$operability = app(TenantOperabilityService::class);
|
||||
|
||||
return $this->tenants()
|
||||
return $operability->filterSelectable($this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
->get());
|
||||
}
|
||||
|
||||
public function getDefaultTenant(Panel $panel): ?Model
|
||||
@ -171,6 +172,7 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
$operability = app(TenantOperabilityService::class);
|
||||
|
||||
$tenantId = null;
|
||||
|
||||
@ -184,19 +186,18 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
if ($tenantId !== null) {
|
||||
$tenant = $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
if ($tenant instanceof Tenant && $operability->canSelectAsContext($tenant)) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->first();
|
||||
->get()
|
||||
->first(fn (Tenant $tenant): bool => $operability->canSelectAsContext($tenant));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
@ -33,6 +34,7 @@ public function log(
|
||||
?string $targetLabel = null,
|
||||
?string $summary = null,
|
||||
?int $operationRunId = null,
|
||||
?Tenant $tenant = null,
|
||||
): \App\Models\AuditLog {
|
||||
$resolvedActor = $actor instanceof User
|
||||
? AuditActorSnapshot::human($actor)
|
||||
@ -47,6 +49,7 @@ public function log(
|
||||
action: $action,
|
||||
context: $context,
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
actor: $resolvedActor,
|
||||
target: new AuditTargetSnapshot(
|
||||
type: $resourceType,
|
||||
@ -59,4 +62,29 @@ public function log(
|
||||
operationRunId: $operationRunId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function logTenantLifecycleAction(
|
||||
Tenant $tenant,
|
||||
string|AuditActionId $action,
|
||||
array $context = [],
|
||||
?User $actor = null,
|
||||
string $status = 'success',
|
||||
?string $summary = null,
|
||||
): \App\Models\AuditLog {
|
||||
return $this->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: $action,
|
||||
context: $context,
|
||||
actor: $actor,
|
||||
status: $status,
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
targetLabel: $tenant->name,
|
||||
summary: $summary,
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@ -16,6 +18,7 @@ class OnboardingDraftResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OnboardingLifecycleService $lifecycleService,
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -43,9 +46,29 @@ public function resolve(TenantOnboardingSession|int|string $draft, User $user, W
|
||||
|
||||
Gate::forUser($user)->authorize('view', $resolvedDraft);
|
||||
|
||||
return $this->lifecycleService
|
||||
$resolvedDraft = $this->lifecycleService
|
||||
->syncPersistedLifecycle($resolvedDraft)
|
||||
->loadMissing(['tenant', 'startedByUser', 'updatedByUser']);
|
||||
|
||||
$normalizedTenant = $this->lifecycleService->syncLinkedTenantAfterCancellation($resolvedDraft);
|
||||
|
||||
if ($normalizedTenant !== null) {
|
||||
$this->auditLogger->logTenantLifecycleAction(
|
||||
tenant: $normalizedTenant,
|
||||
action: AuditActionId::TenantReturnedToDraft,
|
||||
actor: $user,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source' => 'onboarding_draft_resolver',
|
||||
'onboarding_session_id' => (int) $resolvedDraft->getKey(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$resolvedDraft->setRelation('tenant', $normalizedTenant);
|
||||
}
|
||||
|
||||
return $resolvedDraft;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -60,17 +83,20 @@ public function resumableDraftsFor(User $user, Workspace $workspace): Collection
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
return $drafts->filter(function (TenantOnboardingSession $draft) use ($user): bool {
|
||||
try {
|
||||
Gate::forUser($user)->authorize('view', $draft);
|
||||
} catch (AuthorizationException) {
|
||||
return false;
|
||||
}
|
||||
return $drafts
|
||||
->map(function (TenantOnboardingSession $draft) use ($user): ?TenantOnboardingSession {
|
||||
try {
|
||||
Gate::forUser($user)->authorize('view', $draft);
|
||||
} catch (AuthorizationException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return true;
|
||||
})->map(fn (TenantOnboardingSession $draft): TenantOnboardingSession => $this->lifecycleService
|
||||
->syncPersistedLifecycle($draft)
|
||||
->loadMissing(['tenant', 'startedByUser', 'updatedByUser']))
|
||||
return $this->lifecycleService
|
||||
->syncPersistedLifecycle($draft)
|
||||
->loadMissing(['tenant', 'startedByUser', 'updatedByUser']);
|
||||
})
|
||||
->filter(fn (?TenantOnboardingSession $draft): bool => $draft instanceof TenantOnboardingSession
|
||||
&& $this->lifecycleService->canResumeDraft($draft))
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -15,6 +17,10 @@
|
||||
|
||||
class OnboardingLifecycleService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantOperabilityService $tenantOperabilityService,
|
||||
) {}
|
||||
|
||||
public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $incrementVersion = false): TenantOnboardingSession
|
||||
{
|
||||
$freshDraft = TenantOnboardingSession::query()->whereKey($draft->getKey())->first();
|
||||
@ -392,6 +398,49 @@ public function hasActiveCheckpoint(TenantOnboardingSession $draft): bool
|
||||
return in_array($snapshot['lifecycle_state'], [OnboardingLifecycleState::Verifying, OnboardingLifecycleState::Bootstrapping], true);
|
||||
}
|
||||
|
||||
public function canResumeDraft(TenantOnboardingSession $draft): bool
|
||||
{
|
||||
if (! $draft->isWorkflowResumable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = $draft->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->tenantOperabilityService->canResumeOnboarding($tenant);
|
||||
}
|
||||
|
||||
public function syncLinkedTenantAfterCancellation(TenantOnboardingSession $draft): ?Tenant
|
||||
{
|
||||
$tenant = $draft->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($tenant->trashed() || $tenant->status !== Tenant::STATUS_ONBOARDING || ! $draft->isCancelled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasOtherResumableDrafts = TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $draft->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereKeyNot((int) $draft->getKey())
|
||||
->resumable()
|
||||
->exists();
|
||||
|
||||
if ($hasOtherResumableDrafts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant->forceFill(['status' => Tenant::STATUS_DRAFT])->save();
|
||||
|
||||
return $tenant->fresh();
|
||||
}
|
||||
|
||||
private function hasTenantIdentity(TenantOnboardingSession $draft): bool
|
||||
{
|
||||
if ($draft->tenant_id !== null) {
|
||||
|
||||
76
app/Services/Tenants/TenantOperabilityService.php
Normal file
76
app/Services/Tenants/TenantOperabilityService.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Tenants;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Tenants\TenantLifecycle;
|
||||
use App\Support\Tenants\TenantOperabilityDecision;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TenantOperabilityService
|
||||
{
|
||||
public function decisionFor(Tenant $tenant): TenantOperabilityDecision
|
||||
{
|
||||
$lifecycle = TenantLifecycle::fromTenant($tenant);
|
||||
$isArchived = $tenant->trashed() || $lifecycle === TenantLifecycle::Archived;
|
||||
|
||||
return new TenantOperabilityDecision(
|
||||
lifecycle: $lifecycle,
|
||||
canViewTenantSurface: $lifecycle->canViewTenantSurface(),
|
||||
canSelectAsContext: ! $tenant->trashed() && $lifecycle->canSelectAsContext(),
|
||||
canOperate: ! $tenant->trashed() && $lifecycle->canOperate(),
|
||||
canArchive: ! $isArchived && $lifecycle->canArchive(),
|
||||
canRestore: $isArchived || $lifecycle->canRestore(),
|
||||
canResumeOnboarding: ! $tenant->trashed() && $lifecycle->canResumeOnboarding(),
|
||||
canReferenceInWorkspaceMonitoring: $lifecycle->canReferenceInWorkspaceMonitoring(),
|
||||
);
|
||||
}
|
||||
|
||||
public function lifecycleFor(Tenant $tenant): TenantLifecycle
|
||||
{
|
||||
return $this->decisionFor($tenant)->lifecycle;
|
||||
}
|
||||
|
||||
public function canSelectAsContext(Tenant $tenant): bool
|
||||
{
|
||||
return $this->decisionFor($tenant)->canSelectAsContext;
|
||||
}
|
||||
|
||||
public function canViewTenantSurface(Tenant $tenant): bool
|
||||
{
|
||||
return $this->decisionFor($tenant)->canViewTenantSurface;
|
||||
}
|
||||
|
||||
public function canResumeOnboarding(Tenant $tenant): bool
|
||||
{
|
||||
return $this->decisionFor($tenant)->canResumeOnboarding;
|
||||
}
|
||||
|
||||
public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
||||
{
|
||||
return $this->decisionFor($tenant)->canReferenceInWorkspaceMonitoring;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Tenant> $tenants
|
||||
* @return Collection<int, Tenant>
|
||||
*/
|
||||
public function filterSelectable(Collection $tenants): Collection
|
||||
{
|
||||
return $tenants
|
||||
->filter(fn (mixed $tenant): bool => $tenant instanceof Tenant && $this->canSelectAsContext($tenant))
|
||||
->values();
|
||||
}
|
||||
|
||||
public function applySelectableScope(Builder $query, ?string $table = null): Builder
|
||||
{
|
||||
$prefix = $table !== null && $table !== '' ? "{$table}." : '';
|
||||
|
||||
return $query
|
||||
->whereNull("{$prefix}deleted_at")
|
||||
->where("{$prefix}status", TenantLifecycle::Active->value);
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,10 @@ enum AuditActionId: string
|
||||
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
|
||||
case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner';
|
||||
|
||||
case TenantArchived = 'tenant.archived';
|
||||
case TenantRestored = 'tenant.restored';
|
||||
case TenantReturnedToDraft = 'tenant.returned_to_draft';
|
||||
|
||||
case TenantMembershipAdd = 'tenant_membership.add';
|
||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
||||
case TenantMembershipRemove = 'tenant_membership.remove';
|
||||
@ -129,6 +133,9 @@ private static function labels(): array
|
||||
self::WorkspaceMembershipRoleChange->value => 'Workspace member role change',
|
||||
self::WorkspaceMembershipRemove->value => 'Workspace member removal',
|
||||
self::WorkspaceMembershipLastOwnerBlocked->value => 'Workspace last-owner protection',
|
||||
self::TenantArchived->value => 'Tenant archived',
|
||||
self::TenantRestored->value => 'Tenant restored',
|
||||
self::TenantReturnedToDraft->value => 'Tenant returned to draft',
|
||||
self::TenantMembershipAdd->value => 'Tenant member add',
|
||||
self::TenantMembershipRoleChange->value => 'Tenant member role change',
|
||||
self::TenantMembershipRemove->value => 'Tenant member removal',
|
||||
@ -220,6 +227,9 @@ private static function summaries(): array
|
||||
self::WorkspaceMembershipRoleChange->value => 'Workspace member role changed',
|
||||
self::WorkspaceMembershipRemove->value => 'Workspace member removed',
|
||||
self::WorkspaceMembershipLastOwnerBlocked->value => 'Workspace last-owner protection triggered',
|
||||
self::TenantArchived->value => 'Tenant archived',
|
||||
self::TenantRestored->value => 'Tenant restored',
|
||||
self::TenantReturnedToDraft->value => 'Tenant returned to draft',
|
||||
self::TenantMembershipAdd->value => 'Tenant member added',
|
||||
self::TenantMembershipRoleChange->value => 'Tenant member role changed',
|
||||
self::TenantMembershipRemove->value => 'Tenant member removed',
|
||||
|
||||
@ -155,6 +155,17 @@ public static function normalizeManagedTenantOnboardingVerificationStatus(mixed
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeTenantLifecycle(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'pending' => 'onboarding',
|
||||
'inactive' => 'archived',
|
||||
default => $state,
|
||||
};
|
||||
}
|
||||
|
||||
private static function buildMapper(BadgeDomain $domain): ?BadgeMapper
|
||||
{
|
||||
$mapperClass = self::DOMAIN_MAPPERS[$domain->value] ?? null;
|
||||
|
||||
@ -10,15 +10,13 @@ final class TenantStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$state = BadgeCatalog::normalizeTenantLifecycle($value);
|
||||
|
||||
return match ($state) {
|
||||
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||
'draft' => new BadgeSpec('Draft', 'gray', 'heroicon-m-document'),
|
||||
'onboarding' => new BadgeSpec('Onboarding', 'warning', 'heroicon-m-arrow-path'),
|
||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
||||
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
||||
'suspended' => new BadgeSpec('Suspended', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-archive-box'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -140,7 +140,7 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, $request);
|
||||
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -18,6 +20,7 @@ final class OperateHubShell
|
||||
public function __construct(
|
||||
private WorkspaceContext $workspaceContext,
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
private TenantOperabilityService $tenantOperabilityService,
|
||||
) {}
|
||||
|
||||
public function scopeLabel(?Request $request = null): string
|
||||
@ -83,7 +86,8 @@ public function activeEntitledTenant(?Request $request = null): ?Tenant
|
||||
|
||||
private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
{
|
||||
$routeTenant = $this->resolveRouteTenant($request);
|
||||
$pageCategory = TenantPageCategory::fromRequest($request);
|
||||
$routeTenant = $this->resolveRouteTenant($request, $pageCategory);
|
||||
|
||||
if ($request?->route()?->hasParameter('tenant')) {
|
||||
return $routeTenant;
|
||||
@ -95,25 +99,21 @@ private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) {
|
||||
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request, $pageCategory)) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$rememberedTenantId = $this->workspaceContext->lastTenantId($request);
|
||||
|
||||
if ($rememberedTenantId === null) {
|
||||
return null;
|
||||
if ($tenant instanceof Tenant && ! $this->tenantOperabilityService->canSelectAsContext($tenant)) {
|
||||
Filament::setTenant(null, true);
|
||||
}
|
||||
|
||||
$rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first();
|
||||
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
|
||||
|
||||
if (! $rememberedTenant instanceof Tenant) {
|
||||
$this->workspaceContext->clearLastTenantId($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isEntitled($rememberedTenant, $request)) {
|
||||
if (! $this->isEntitled($rememberedTenant, $request, $pageCategory)) {
|
||||
$this->workspaceContext->clearLastTenantId($request);
|
||||
|
||||
return null;
|
||||
@ -122,33 +122,64 @@ private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
return $rememberedTenant;
|
||||
}
|
||||
|
||||
private function resolveRouteTenant(?Request $request = null): ?Tenant
|
||||
private function resolveRouteTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
||||
{
|
||||
$route = $request?->route();
|
||||
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
||||
|
||||
if (! $route?->hasParameter('tenant')) {
|
||||
if ($route?->hasParameter('tenant')) {
|
||||
$tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $this->isEntitled($tenant, $request, $pageCategory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
if (
|
||||
$pageCategory !== TenantPageCategory::TenantBound
|
||||
|| ! $route?->hasParameter('record')
|
||||
|| ! str_starts_with((string) ($route->getName() ?? ''), 'filament.admin.resources.tenants.')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$routeTenant = $route->parameter('tenant');
|
||||
$tenant = $this->resolveTenantRouteParameter($route->parameter('record'));
|
||||
|
||||
$tenant = $routeTenant instanceof Tenant
|
||||
? $routeTenant
|
||||
: Tenant::query()->withTrashed()->where('external_id', (string) $routeTenant)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $this->isEntitled($tenant, $request)) {
|
||||
if (! $tenant instanceof Tenant || ! $this->isEntitled($tenant, $request, $pageCategory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function isEntitled(Tenant $tenant, ?Request $request = null): bool
|
||||
private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
|
||||
{
|
||||
if (! $tenant->isActive()) {
|
||||
return false;
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
$routeTenant = trim((string) $routeTenant);
|
||||
|
||||
if ($routeTenant === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->withTrashed()
|
||||
->where(static function ($query) use ($routeTenant): void {
|
||||
$query->where('external_id', $routeTenant);
|
||||
|
||||
if (ctype_digit($routeTenant)) {
|
||||
$query->orWhereKey((int) $routeTenant);
|
||||
}
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
private function isEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -161,6 +192,16 @@ private function isEntitled(Tenant $tenant, ?Request $request = null): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->capabilityResolver->isMember($user, $tenant);
|
||||
if (! $this->capabilityResolver->isMember($user, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decision = $this->tenantOperabilityService->decisionFor($tenant);
|
||||
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
||||
|
||||
return match ($pageCategory) {
|
||||
TenantPageCategory::TenantBound => $decision->canViewTenantSurface,
|
||||
default => $decision->canSelectAsContext,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
121
app/Support/Tenants/TenantLifecycle.php
Normal file
121
app/Support/Tenants/TenantLifecycle.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Tenants;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use BackedEnum;
|
||||
use Stringable;
|
||||
|
||||
enum TenantLifecycle: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Onboarding = 'onboarding';
|
||||
case Active = 'active';
|
||||
case Archived = 'archived';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $lifecycle): string => $lifecycle->value,
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromTenant(Tenant $tenant): self
|
||||
{
|
||||
if ($tenant->trashed()) {
|
||||
return self::Archived;
|
||||
}
|
||||
|
||||
return self::fromValue($tenant->status);
|
||||
}
|
||||
|
||||
public static function fromValue(mixed $value, self $default = self::Active): self
|
||||
{
|
||||
return self::tryFromValue($value) ?? $default;
|
||||
}
|
||||
|
||||
public static function tryFromValue(mixed $value): ?self
|
||||
{
|
||||
$normalized = self::normalize($value);
|
||||
|
||||
return $normalized === null ? null : self::tryFrom($normalized);
|
||||
}
|
||||
|
||||
public static function normalize(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value instanceof BackedEnum) {
|
||||
$value = $value->value;
|
||||
}
|
||||
|
||||
if ($value instanceof Stringable) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim($value));
|
||||
$normalized = str_replace([' ', '-'], '_', $normalized);
|
||||
|
||||
return match ($normalized) {
|
||||
'pending' => self::Onboarding->value,
|
||||
default => $normalized !== '' ? $normalized : null,
|
||||
};
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Draft',
|
||||
self::Onboarding => 'Onboarding',
|
||||
self::Active => 'Active',
|
||||
self::Archived => 'Archived',
|
||||
};
|
||||
}
|
||||
|
||||
public function canSelectAsContext(): bool
|
||||
{
|
||||
return $this === self::Active;
|
||||
}
|
||||
|
||||
public function canViewTenantSurface(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function canOperate(): bool
|
||||
{
|
||||
return $this === self::Active;
|
||||
}
|
||||
|
||||
public function canArchive(): bool
|
||||
{
|
||||
return $this !== self::Archived;
|
||||
}
|
||||
|
||||
public function canRestore(): bool
|
||||
{
|
||||
return $this === self::Archived;
|
||||
}
|
||||
|
||||
public function canResumeOnboarding(): bool
|
||||
{
|
||||
return in_array($this, [self::Draft, self::Onboarding], true);
|
||||
}
|
||||
|
||||
public function canReferenceInWorkspaceMonitoring(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
19
app/Support/Tenants/TenantOperabilityDecision.php
Normal file
19
app/Support/Tenants/TenantOperabilityDecision.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Tenants;
|
||||
|
||||
final readonly class TenantOperabilityDecision
|
||||
{
|
||||
public function __construct(
|
||||
public TenantLifecycle $lifecycle,
|
||||
public bool $canViewTenantSurface,
|
||||
public bool $canSelectAsContext,
|
||||
public bool $canOperate,
|
||||
public bool $canArchive,
|
||||
public bool $canRestore,
|
||||
public bool $canResumeOnboarding,
|
||||
public bool $canReferenceInWorkspaceMonitoring,
|
||||
) {}
|
||||
}
|
||||
46
app/Support/Tenants/TenantPageCategory.php
Normal file
46
app/Support/Tenants/TenantPageCategory.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Tenants;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
enum TenantPageCategory: string
|
||||
{
|
||||
case WorkspaceScoped = 'workspace_scoped';
|
||||
case TenantBound = 'tenant_bound';
|
||||
case OnboardingWorkflow = 'onboarding_workflow';
|
||||
case CanonicalWorkspaceRecordViewer = 'canonical_workspace_record_viewer';
|
||||
|
||||
public static function fromRequest(?Request $request = null): self
|
||||
{
|
||||
if (! $request instanceof Request) {
|
||||
return self::WorkspaceScoped;
|
||||
}
|
||||
|
||||
return self::fromPath('/'.ltrim($request->path(), '/'));
|
||||
}
|
||||
|
||||
public static function fromPath(string $path): self
|
||||
{
|
||||
$normalizedPath = '/'.ltrim($path, '/');
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) {
|
||||
return self::CanonicalWorkspaceRecordViewer;
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $normalizedPath) === 1) {
|
||||
return self::OnboardingWorkflow;
|
||||
}
|
||||
|
||||
if (
|
||||
preg_match('#^/admin/t/[^/]+(?:/|$)#', $normalizedPath) === 1
|
||||
|| preg_match('#^/admin/tenants/[^/]+(?:/|$)#', $normalizedPath) === 1
|
||||
) {
|
||||
return self::TenantBound;
|
||||
}
|
||||
|
||||
return self::WorkspaceScoped;
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Support\Workspaces;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class WorkspaceContext
|
||||
@ -15,7 +17,10 @@ final class WorkspaceContext
|
||||
|
||||
public const LAST_TENANT_IDS_SESSION_KEY = 'workspace_last_tenant_ids';
|
||||
|
||||
public function __construct(private WorkspaceResolver $resolver) {}
|
||||
public function __construct(
|
||||
private WorkspaceResolver $resolver,
|
||||
private TenantOperabilityService $tenantOperabilityService,
|
||||
) {}
|
||||
|
||||
public function currentWorkspaceId(?Request $request = null): ?int
|
||||
{
|
||||
@ -69,6 +74,25 @@ public function rememberLastTenantId(int $workspaceId, int $tenantId, ?Request $
|
||||
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
|
||||
}
|
||||
|
||||
public function rememberTenantContext(Tenant $tenant, ?Request $request = null): bool
|
||||
{
|
||||
$workspaceId = $this->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId === null || (int) $tenant->workspace_id !== $workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->tenantOperabilityService->canSelectAsContext($tenant)) {
|
||||
$this->clearLastTenantId($request);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->rememberLastTenantId($workspaceId, (int) $tenant->getKey(), $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function lastTenantId(?Request $request = null): ?int
|
||||
{
|
||||
$workspaceId = $this->currentWorkspaceId($request);
|
||||
@ -105,6 +129,46 @@ public function clearLastTenantId(?Request $request = null): void
|
||||
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
|
||||
}
|
||||
|
||||
public function rememberedTenant(?Request $request = null): ?Tenant
|
||||
{
|
||||
$workspaceId = $this->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rememberedTenantId = $this->lastTenantId($request);
|
||||
|
||||
if ($rememberedTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->withTrashed()
|
||||
->whereKey($rememberedTenantId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->clearLastTenantId($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $tenant->workspace_id !== $workspaceId) {
|
||||
$this->clearLastTenantId($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->tenantOperabilityService->canSelectAsContext($tenant)) {
|
||||
$this->clearLastTenantId($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
|
||||
{
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
|
||||
/**
|
||||
* Resolves the explicit post-selection destination after a workspace is set.
|
||||
@ -20,6 +21,10 @@
|
||||
*/
|
||||
final class WorkspaceRedirectResolver
|
||||
{
|
||||
public function __construct(
|
||||
private TenantOperabilityService $tenantOperabilityService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the redirect URL for the given workspace + user.
|
||||
*
|
||||
@ -27,11 +32,12 @@ final class WorkspaceRedirectResolver
|
||||
*/
|
||||
public function resolve(Workspace $workspace, User $user): string
|
||||
{
|
||||
$tenantsQuery = $user->tenants()
|
||||
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants()
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->where('status', 'active');
|
||||
->orderBy('name')
|
||||
->get());
|
||||
|
||||
$tenantCount = (int) $tenantsQuery->count();
|
||||
$tenantCount = (int) $selectableTenants->count();
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
return route('admin.workspace.managed-tenants.index', [
|
||||
@ -40,7 +46,7 @@ public function resolve(Workspace $workspace, User $user): string
|
||||
}
|
||||
|
||||
if ($tenantCount === 1) {
|
||||
$tenant = $tenantsQuery->first();
|
||||
$tenant = $selectableTenants->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
|
||||
@ -57,4 +57,28 @@ public function definition(): array
|
||||
'completed_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function forTenant(Tenant $tenant): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function tenantlessForWorkspace(Workspace $workspace): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'tenant_id' => null,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function queued(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,4 +50,37 @@ public function definition(): array
|
||||
'rbac_last_checked_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function draft(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => Tenant::STATUS_DRAFT,
|
||||
'is_current' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function onboarding(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'is_current' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function active(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => Tenant::STATUS_ACTIVE,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function archived(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => Tenant::STATUS_ARCHIVED,
|
||||
'deleted_at' => now(),
|
||||
'is_current' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,4 +147,12 @@ public function cancelled(): static
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function resumableForTenant(Tenant $tenant): static
|
||||
{
|
||||
return $this->forTenant($tenant)->state(fn (): array => [
|
||||
'completed_at' => null,
|
||||
'cancelled_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
265
docs/audits/2026-03-15-audit-spec-candidates.md
Normal file
265
docs/audits/2026-03-15-audit-spec-candidates.md
Normal file
@ -0,0 +1,265 @@
|
||||
# Audit-Derived Spec Candidates
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Source audit:** [docs/audits/tenantpilot-architecture-audit-constitution.md](docs/audits/tenantpilot-architecture-audit-constitution.md) plus first-pass repo scan driven by [ .github/prompts/tenantpilot.audit.prompt.md ](.github/prompts/tenantpilot.audit.prompt.md)
|
||||
|
||||
## Goal
|
||||
|
||||
Translate the first architecture and safety audit findings into a small number of high-value follow-up specs.
|
||||
|
||||
These are intentionally **problem-cluster specs**, not bug tickets. Each candidate groups multiple symptoms under one architectural diagnosis so the repo does not fragment into dozens of local fixes.
|
||||
|
||||
## Recommended Order
|
||||
|
||||
1. Spec 144: queued execution reauthorization and scope continuity
|
||||
2. Spec 145: tenant-owned query canon and wrong-tenant regression guards
|
||||
3. Spec 146: findings workflow enforcement and audit backstop
|
||||
4. Spec 147: Livewire context locking and trusted-state reduction
|
||||
|
||||
## Candidate 144
|
||||
|
||||
### Proposed slug
|
||||
|
||||
`144-queued-execution-reauthorization-scope-continuity`
|
||||
|
||||
### Architectural diagnosis
|
||||
|
||||
Queued work currently relies too heavily on the authorization and tenant state that existed at dispatch time. That is acceptable for UX initiation, but not as the final trust boundary for execution.
|
||||
|
||||
### Primary findings covered
|
||||
|
||||
- Job authorization revalidation is incomplete.
|
||||
- Jobs resolve tenant records by ID without a canonical execution-time scope recheck.
|
||||
- Execution truth is tracked by `OperationRun`, but execution legitimacy is not revalidated as a first-class concern.
|
||||
|
||||
### Why this is not already covered
|
||||
|
||||
- [specs/110-ops-ux-enforcement/spec.md](specs/110-ops-ux-enforcement/spec.md) standardizes `OperationRun` lifecycle and notifications, not actor reauthorization or tenant lifecycle rechecks during execution.
|
||||
- [specs/143-tenant-lifecycle-operability-context-semantics/spec.md](specs/143-tenant-lifecycle-operability-context-semantics/spec.md) hardens run viewing semantics, not job execution semantics.
|
||||
- [specs/049-backup-restore-job-orchestration/spec.md](specs/049-backup-restore-job-orchestration/spec.md) predates the newer constitutional model and is too narrow.
|
||||
|
||||
### Scope
|
||||
|
||||
- queued jobs that mutate tenant-owned data or trigger provider work
|
||||
- execution middleware and shared job execution helpers
|
||||
- actor snapshot versus current authorization semantics
|
||||
- tenant archived, deleted, or disabled execution handling
|
||||
- auditable execution denial or cancellation outcomes
|
||||
|
||||
### Must-answer questions
|
||||
|
||||
- What is the canonical execution identity: original actor, current actor capabilities, or system-owned delegated authority?
|
||||
- What should happen if the tenant is archived, detached, or otherwise no longer executable when the job starts?
|
||||
- Which failures are terminal authorization failures versus retryable precondition failures?
|
||||
- How should denial-at-execution be represented in `OperationRun` and `AuditLog`?
|
||||
|
||||
### Expected requirements shape
|
||||
|
||||
- Define a canonical execution revalidation contract for queued jobs.
|
||||
- Require tenant existence and tenant operability checks before side effects.
|
||||
- Require capability revalidation for human-initiated jobs where authority is expected to remain actor-bound.
|
||||
- Define when a job may continue under system authority and when it must fail closed.
|
||||
- Add regression coverage for role downgrade, tenant archival, tenant deletion, and stale actor context.
|
||||
|
||||
### Suggested success criteria
|
||||
|
||||
- No in-scope job mutates tenant-owned state after tenant archival or actor deauthorization.
|
||||
- Execution-denied jobs produce deterministic `OperationRun` outcome and auditable reason codes.
|
||||
- Focused regression coverage exists for each representative operation family.
|
||||
|
||||
### Delivery recommendation
|
||||
|
||||
Dedicated spec required.
|
||||
|
||||
## Candidate 145
|
||||
|
||||
### Proposed slug
|
||||
|
||||
`145-tenant-owned-query-canon-and-wrong-tenant-guards`
|
||||
|
||||
### Architectural diagnosis
|
||||
|
||||
Tenant isolation is mostly implemented, but the query layer remains too ad hoc. The repo relies on many repeated `where('tenant_id', ...)` patterns across resources, widgets, and pages. That creates drift risk and weakens systematic negative testing.
|
||||
|
||||
### Primary findings covered
|
||||
|
||||
- Policy queries and similar surfaces use ad hoc tenant scoping instead of canonical model-level resolution.
|
||||
- Wrong-tenant regression coverage is uneven across resources, actions, detail pages, and bulk operations.
|
||||
- The system has constitutional isolation rules, but not yet a sufficiently reusable query canon plus guardrail suite.
|
||||
|
||||
### Why this is not already covered
|
||||
|
||||
- [specs/135-canonical-tenant-context-resolution/spec.md](specs/135-canonical-tenant-context-resolution/spec.md) and [specs/136-admin-canonical-tenant/spec.md](specs/136-admin-canonical-tenant/spec.md) focus on resolving the correct active tenant context on admin and monitoring surfaces.
|
||||
- They do not define a tenant-owned model query contract or a repeatable wrong-tenant regression matrix for resources and actions.
|
||||
|
||||
### Scope
|
||||
|
||||
- tenant-owned Eloquent models and shared scoping helpers
|
||||
- Filament resource queries, widgets, relation managers, and sensitive actions
|
||||
- route-model lookup hardening where tenant-owned records are resolved
|
||||
- regression tests for wrong-tenant index, detail, row action, and bulk action paths
|
||||
|
||||
### Must-answer questions
|
||||
|
||||
- Is the canonical pattern a shared trait, explicit local scopes, or a resolver-backed query helper?
|
||||
- Which model families are officially tenant-owned and therefore in scope for mandatory canonical query helpers?
|
||||
- Which action classes count as mandatory wrong-tenant regression surfaces?
|
||||
- Where should 404 versus 403 semantics be asserted in tests for members versus non-members?
|
||||
|
||||
### Expected requirements shape
|
||||
|
||||
- Define a canonical query entry pattern for tenant-owned models.
|
||||
- Ban free-form tenant scoping on defined sensitive model families except for documented edge cases.
|
||||
- Require focused wrong-tenant regression coverage on key resources and sensitive actions.
|
||||
- Add a lightweight static or grep-style guard where it is safe and low-noise.
|
||||
|
||||
### Suggested success criteria
|
||||
|
||||
- All tier-1 tenant-owned resources use canonical query helpers instead of ad hoc per-page filtering.
|
||||
- Focused wrong-tenant regression coverage exists for table rows, detail pages, and bulk actions on tier-1 surfaces.
|
||||
- No newly introduced sensitive action can execute against a record from a foreign tenant context.
|
||||
|
||||
### Delivery recommendation
|
||||
|
||||
Dedicated spec required.
|
||||
|
||||
## Candidate 146
|
||||
|
||||
### Proposed slug
|
||||
|
||||
`146-findings-workflow-enforcement-and-audit-backstop`
|
||||
|
||||
### Architectural diagnosis
|
||||
|
||||
The findings workflow spec is strong at the product level, but the enforcement model is still too soft. Transition validity and auditability depend too much on service-path discipline instead of being impossible to bypass.
|
||||
|
||||
### Primary findings covered
|
||||
|
||||
- Critical status transitions lack centralized enforcement.
|
||||
- Audit trail for finding status transitions depends on `FindingWorkflowService` invocation, not a model-level or domain-level backstop.
|
||||
- Direct status mutation remains possible in principle even though the intended workflow path is specified.
|
||||
|
||||
### Why this is not already covered
|
||||
|
||||
- [specs/111-findings-workflow-sla/spec.md](specs/111-findings-workflow-sla/spec.md) defines allowed transitions and expected audit behavior, but does not yet settle the enforcement mechanism strongly enough.
|
||||
- The audit finding is not about missing product semantics; it is about missing architectural hardening of those semantics.
|
||||
|
||||
### Scope
|
||||
|
||||
- `Finding` transition enforcement model
|
||||
- allowed versus forbidden mutations outside workflow services
|
||||
- audit backstop for all meaningful finding lifecycle changes
|
||||
- recurrence, reopen, auto-resolve, and direct update edge cases
|
||||
- tests for invalid transition attempts and missing-audit bypass attempts
|
||||
|
||||
### Must-answer questions
|
||||
|
||||
- Is the source of truth a formal state machine, model guard, custom cast, or service-only write API with hard enforcement?
|
||||
- What model events are safe for audit backstop versus too implicit for domain truth?
|
||||
- How should auto-resolve and recurrence semantics interact with the same transition gate?
|
||||
- Do other lifecycle-heavy models need the same pattern once Findings is hardened?
|
||||
|
||||
### Expected requirements shape
|
||||
|
||||
- Formalize transition enforcement, not just transition documentation.
|
||||
- Require all meaningful status mutations to pass through a canonical transition gateway.
|
||||
- Add an audit backstop so status-changing writes cannot silently escape history.
|
||||
- Add negative tests for forbidden transitions, bypass attempts, and recurrence edge cases.
|
||||
|
||||
### Suggested success criteria
|
||||
|
||||
- No invalid finding status transition is possible through service, model, action, or direct update path in covered flows.
|
||||
- Every meaningful finding lifecycle mutation is auditable.
|
||||
- Regression tests fail when a bypass path is introduced.
|
||||
|
||||
### Delivery recommendation
|
||||
|
||||
Dedicated spec required.
|
||||
|
||||
## Candidate 147
|
||||
|
||||
### Proposed slug
|
||||
|
||||
`147-livewire-context-locking-and-trusted-state-reduction`
|
||||
|
||||
### Architectural diagnosis
|
||||
|
||||
Some complex Livewire and Filament flows still expose too much context identity in public component state. This is not necessarily a current exploit, but it leaves the repo dependent on convention instead of a hardened state model.
|
||||
|
||||
### Primary findings covered
|
||||
|
||||
- Serializable tenant and workspace context exists in public component state without a strong, explicit locking pattern.
|
||||
- Workflow continuity in complex wizard flows still depends partly on client-visible identifiers.
|
||||
- The audit constitution says public state is untrusted, but the repo lacks one reusable hardening standard for these flows.
|
||||
|
||||
### Why this is not already covered
|
||||
|
||||
- [specs/138-managed-tenant-onboarding-draft-identity/spec.md](specs/138-managed-tenant-onboarding-draft-identity/spec.md) improves draft identity and resume semantics.
|
||||
- [specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/spec.md](specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/spec.md) improves lifecycle truth.
|
||||
- Neither spec defines a repo-wide Livewire state-safety pattern for locked IDs, session-derived context, and server-side revalidation across complex components.
|
||||
|
||||
### Scope
|
||||
|
||||
- onboarding wizard
|
||||
- restore and other multi-step resources with public IDs
|
||||
- public Livewire properties carrying tenant, workspace, provider, or foreign record references
|
||||
- `#[Locked]` and equivalent trust-reduction patterns
|
||||
- forged-state and mutated-ID regression tests
|
||||
|
||||
### Must-answer questions
|
||||
|
||||
- Which IDs may remain public but locked, and which should disappear entirely from public component state?
|
||||
- What is the canonical source of truth for active tenant and workspace in component actions: session, route record, shell context, or persisted draft?
|
||||
- How should forged-state detection fail: 404, 403, validation error, or forced context reset?
|
||||
- Which component families are tier-1 and must comply first?
|
||||
|
||||
### Expected requirements shape
|
||||
|
||||
- Define a trusted-state reduction standard for Livewire and Filament components.
|
||||
- Require explicit locking or server-side derivation for ownership-relevant identifiers.
|
||||
- Ban direct trust in mutable client-provided context IDs.
|
||||
- Add forged-state regression coverage for tier-1 wizard and resource flows.
|
||||
|
||||
### Suggested success criteria
|
||||
|
||||
- Tier-1 components no longer rely on mutable public tenant or workspace identifiers for authorization-sensitive decisions.
|
||||
- Forged-state tests fail closed in all covered surfaces.
|
||||
- A reusable pattern exists for future Livewire components.
|
||||
|
||||
### Delivery recommendation
|
||||
|
||||
Dedicated spec required.
|
||||
|
||||
## Deferred Candidate
|
||||
|
||||
### OperationRun result referential integrity
|
||||
|
||||
This remains important, but I would not pull it into the first wave ahead of the four candidates above.
|
||||
|
||||
Reason:
|
||||
|
||||
- It is real architecture debt.
|
||||
- It touches auditability and forensic traceability.
|
||||
- But it is less immediately exploitable than execution reauthorization, query canon drift, workflow bypass, or mutable Livewire context.
|
||||
|
||||
Recommended treatment:
|
||||
|
||||
- Track as a likely follow-up to [specs/134-audit-log-foundation/spec.md](specs/134-audit-log-foundation/spec.md) and the existing operations lineage work.
|
||||
- Reassess after Candidate 144 and Candidate 146, because those may clarify the right run-to-artifact integrity model.
|
||||
|
||||
## Recommendation
|
||||
|
||||
If only one spec is started now, start with **Spec 144**.
|
||||
|
||||
Why:
|
||||
|
||||
- It is the cleanest gap between current constitutional claims and actual backend execution trust.
|
||||
- It cuts across restore, sync, and other high-value operations.
|
||||
- It prevents the class of bug where the UI was right at dispatch time but wrong at execution time.
|
||||
|
||||
If two specs are started in parallel, pair **144** with **146**:
|
||||
|
||||
- 144 hardens execution trust.
|
||||
- 146 hardens lifecycle truth and auditability.
|
||||
|
||||
That combination improves the backend trust model faster than a purely UI- or test-first follow-up.
|
||||
451
docs/audits/tenantpilot-architecture-audit-constitution.md
Normal file
451
docs/audits/tenantpilot-architecture-audit-constitution.md
Normal file
@ -0,0 +1,451 @@
|
||||
# TenantPilot Architecture Audit Constitution
|
||||
|
||||
## Purpose
|
||||
|
||||
This constitution defines the non-negotiable architecture, security, and workflow rules for TenantPilot / TenantAtlas.
|
||||
|
||||
It is the standard for every AI or human repository audit. Audits must not stop at local correctness or framework best practices. They must evaluate whether the implementation violates, bypasses, or dilutes the intended enterprise SaaS operating model.
|
||||
|
||||
The audit focuses on:
|
||||
|
||||
- workspace and tenant isolation
|
||||
- capability-first RBAC
|
||||
- auditable operations
|
||||
- safe Livewire and Filament interaction
|
||||
- deterministic workflow semantics
|
||||
- consistent information architecture
|
||||
- provider and job boundaries
|
||||
- negative-path test coverage
|
||||
|
||||
## Product Context
|
||||
|
||||
TenantPilot is not a generic admin panel and not low-risk CRUD software.
|
||||
|
||||
It is an enterprise SaaS platform for Intune and Microsoft 365 governance, with emphasis on:
|
||||
|
||||
- backup, restore, and versioning
|
||||
- inventory, drift, findings, and exceptions
|
||||
- operations, monitoring, and auditability
|
||||
- a workspace-first operating model
|
||||
- tenant-bound managed data
|
||||
- security- and compliance-sensitive workflows
|
||||
|
||||
All implementations must be judged against this target model, not against Laravel or Filament defaults.
|
||||
|
||||
## I. Constitutional Principles
|
||||
|
||||
### 1. Workspace-first is canonical
|
||||
|
||||
Workspace is the primary operating context.
|
||||
Tenant is a secondary domain context inside a workspace.
|
||||
|
||||
The audit must flag any implementation that:
|
||||
|
||||
- handles tenant context without a workspace frame
|
||||
- introduces competing context sources
|
||||
- resolves scope ad hoc instead of through a canonical path
|
||||
|
||||
### 2. Capability-first RBAC is binding
|
||||
|
||||
Access and mutation are enforced through capabilities, gates, and policies, not through implicit UI assumptions, role names, or navigation alone.
|
||||
|
||||
The audit must flag any implementation that:
|
||||
|
||||
- hides authorization only in the UI
|
||||
- distributes capability decisions inconsistently across related flows
|
||||
- executes actions or jobs without backend rechecks
|
||||
|
||||
### 3. Auditability is mandatory
|
||||
|
||||
Security-relevant and operational changes must be traceable.
|
||||
|
||||
The audit must flag any implementation that:
|
||||
|
||||
- performs relevant mutations without an audit trail
|
||||
- leaves run or workflow outcomes weakly referenced
|
||||
- stores important decisions only in transient UI state
|
||||
|
||||
### 4. Workflow trust is a product feature
|
||||
|
||||
Wizards, compare flows, review flows, findings, exceptions, restore, and other operational experiences must be deterministic, explainable, and repeatable.
|
||||
|
||||
The audit must flag any implementation that:
|
||||
|
||||
- has unclear resume, retry, reopen, or archive semantics
|
||||
- lacks enforced status transition rules
|
||||
- lets UI and backend interpret workflow rules differently
|
||||
|
||||
### 5. Strategic consistency beats local convenience
|
||||
|
||||
A locally convenient fix is unacceptable if it weakens the target model, duplicates domain logic, or introduces new side paths.
|
||||
|
||||
## II. Hard Architecture Invariants
|
||||
|
||||
### A. Context and Ownership
|
||||
|
||||
#### A1. Tenant-sensitive data must only be accessed through canonical context
|
||||
|
||||
Reads and writes for tenant- or workspace-bound data must enforce valid scope.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- direct `find()`, `findOrFail()`, unscoped relation loads, or free-form queries exist on sensitive models
|
||||
- Filament pages, widgets, relation managers, or actions invent their own scope logic
|
||||
- route model binding does not enforce ownership cleanly
|
||||
|
||||
#### A2. Ownership must be explicit and consistent
|
||||
|
||||
Every relevant model must be clearly classifiable as one of:
|
||||
|
||||
- workspace-owned
|
||||
- tenant-owned
|
||||
- system- or platform-owned
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- ownership is implicit, inconsistent, or modeled differently per feature
|
||||
- mixed scopes exist without explicit rules
|
||||
- the data model and UI context model diverge
|
||||
|
||||
#### A3. Cross-tenant leakage is a Severity 1 violation
|
||||
|
||||
Any potential or actual path to read, mutate, or indirectly disclose another tenant's data is a constitutional failure.
|
||||
|
||||
### B. RBAC and Authorization
|
||||
|
||||
#### B1. UI visibility never replaces backend authorization
|
||||
|
||||
`visible()`, `hidden()`, navigation guards, and disabled buttons are UX only.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- mutating actions are only hidden in the UI
|
||||
- backend actions, services, or jobs lack policy or gate enforcement
|
||||
- related records are visible while underlying capability checks are absent
|
||||
|
||||
#### B2. Jobs must revalidate scope and authorization
|
||||
|
||||
Asynchronous or decoupled execution must not trust earlier UI checks.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- jobs are started with IDs and later act blindly
|
||||
- authorization or scope rechecks are missing
|
||||
- job execution is not bound cleanly to workspace, tenant, actor, or run context
|
||||
|
||||
#### B3. Capability drift is an architecture problem
|
||||
|
||||
If related pages, actions, APIs, jobs, or services enforce different capabilities for the same use case, that is architectural drift.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- related entry points enforce different capability models for the same operation
|
||||
- role names are used where capabilities should govern behavior
|
||||
|
||||
### C. Livewire and Filament State Safety
|
||||
|
||||
#### C1. Public component state is untrusted input
|
||||
|
||||
Livewire and Filament state must never be treated as trusted.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- full Eloquent models are stored in public state
|
||||
- sensitive attributes can appear in serialized component payloads
|
||||
- IDs, tenant references, step references, or ownership-relevant values are mutable
|
||||
|
||||
#### C2. Sensitive data must never enter frontend state
|
||||
|
||||
Secrets, provider credentials, tokens, internal diagnostics, or similar sensitive values must not appear in serializable public properties or view context.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- models with sensitive fields are directly bound to public state
|
||||
- hidden attributes, DTOs, locked properties, or server-side reconstruction are missing
|
||||
|
||||
#### C3. Workflow progress must not depend only on UI state
|
||||
|
||||
Resume, step access, completion, and action eligibility must be derived server-side from persisted truth.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- wizard steps are controlled only by frontend state
|
||||
- direct jumps are possible without backend validation
|
||||
- meaningful status exists only in the form state
|
||||
|
||||
### D. Workflow Integrity
|
||||
|
||||
#### D1. Status models need formal transitions
|
||||
|
||||
Status fields are domain logic, not decoration.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- free-form strings are used without transition guards
|
||||
- forbidden transitions are technically possible
|
||||
- different code paths interpret the same status differently
|
||||
|
||||
#### D2. Resume, retry, reopen, and archive must be deterministic
|
||||
|
||||
Every operational flow needs clear rules for:
|
||||
|
||||
- what resumes
|
||||
- what creates a new run or object
|
||||
- what is idempotent
|
||||
- what is rejected
|
||||
- what is auditable
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- operator behavior is ambiguous
|
||||
- refresh, revisit, back, or retry semantics depend on incidental state
|
||||
- parallel sessions are unhandled
|
||||
|
||||
#### D3. Findings, exceptions, and risk acceptance need coherent lifecycles
|
||||
|
||||
Status, expiry, renewal, reopen, recurrence, and ownership semantics must align in both domain and implementation.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- UI actions permit more than the domain model allows
|
||||
- expiry or renewal semantics are unclear or partial
|
||||
- recurrence or reopen logic is inconsistent
|
||||
|
||||
### E. Operations, Jobs, and Provider Boundaries
|
||||
|
||||
#### E1. External provider access must stay inside defined boundaries
|
||||
|
||||
Microsoft Graph or other provider interactions must not be initiated from UI pages, Filament actions, widgets, or arbitrary services.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- direct SDK, HTTP, or Graph calls appear outside the intended gateway or resolver layer
|
||||
- provider resolvers are bypassed
|
||||
- provider-specific types leak unchecked into domain logic
|
||||
|
||||
#### E2. OperationRun is canonical for operational execution
|
||||
|
||||
Meaningful operations must be traceable through run semantics, status, failure paths, and result references.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- significant operations run without `OperationRun`
|
||||
- result artifacts are not bound to runs
|
||||
- UI intent and actual execution drift apart
|
||||
|
||||
#### E3. Idempotency is mandatory for repeatable operations
|
||||
|
||||
Capture, compare, sync, review, alerts, and similar operations must not create duplicate or contradictory results without explicit design.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- fingerprints, deduplication, or run correlation are absent where required
|
||||
- retry can create uncontrolled duplicates
|
||||
|
||||
### F. Auditability and Evidence
|
||||
|
||||
#### F1. Critical mutations require an audit trail
|
||||
|
||||
The audit must verify whether it is traceable:
|
||||
|
||||
- who acted
|
||||
- in which scope
|
||||
- with which action type or intent
|
||||
- with what outcome
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- mutating flows occur without `AuditLog` or an equivalent trace
|
||||
- exception or risk-acceptance decisions are not durably recorded
|
||||
|
||||
#### F2. Reports, findings, evidence, runs, and exceptions must remain referential
|
||||
|
||||
Governance-relevant artifacts need stable relationships.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- run results exist only in transient context
|
||||
- findings cannot be traced to source evidence
|
||||
- reports and evidence are only loosely or implicitly connected
|
||||
|
||||
### G. Tests as a Security Boundary
|
||||
|
||||
#### G1. Happy-path-only coverage is insufficient
|
||||
|
||||
Security- or workflow-critical areas require negative tests.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- only visibility or standard CRUD paths are covered
|
||||
- wrong-tenant, unauthorized, expired, invalid-transition, or forged-state paths are absent
|
||||
|
||||
#### G2. Wrong-tenant tests are mandatory
|
||||
|
||||
Tenant-sensitive resources, pages, actions, detail views, operations, and relevant APIs must prove that foreign-scope access fails.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- systematic wrong-tenant regression coverage is missing
|
||||
|
||||
#### G3. Workflow misuse tests are mandatory
|
||||
|
||||
Wizards, findings, exceptions, reviews, runs, retry, and resume semantics must have misuse or failure-path coverage.
|
||||
|
||||
Audit finding if:
|
||||
|
||||
- invalid status jumps
|
||||
- expired exception paths
|
||||
- manipulated IDs
|
||||
- duplicate operations
|
||||
- race or retry failures
|
||||
|
||||
are not tested
|
||||
|
||||
## III. Forbidden Anti-Patterns
|
||||
|
||||
The auditor must explicitly search for and flag these patterns:
|
||||
|
||||
- tenant-sensitive `find()` or `findOrFail()` without scope hardening
|
||||
- direct provider or Graph calls in Filament pages, actions, widgets, or Livewire components
|
||||
- public Livewire properties containing full Eloquent models
|
||||
- mutable foreign IDs or references without locking or server revalidation
|
||||
- `Model::create($request->all())` or equivalent uncontrolled mass assignment in critical flows
|
||||
- UI-only authorization
|
||||
- jobs without repeated scope or capability checks
|
||||
- free-form status strings without transition rules
|
||||
- business rules duplicated across pages or resources instead of centralized
|
||||
- ad hoc context determination instead of canonical resolvers
|
||||
- relevant operations without `OperationRun`
|
||||
- relevant mutations without audit trail
|
||||
- missing wrong-tenant negative tests
|
||||
|
||||
## IV. Finding Classification
|
||||
|
||||
### 1. Constitutional Violation
|
||||
|
||||
Breaks a hard constitutional rule.
|
||||
|
||||
Examples:
|
||||
|
||||
- potential cross-tenant access
|
||||
- missing backend authorization
|
||||
- sensitive data in serialized UI state
|
||||
- direct provider-boundary bypass
|
||||
|
||||
### 2. Architectural Drift
|
||||
|
||||
Code works locally but deviates from the strategic target model.
|
||||
|
||||
Examples:
|
||||
|
||||
- parallel context paths
|
||||
- inconsistent run semantics
|
||||
- duplicated domain rules across UI surfaces
|
||||
|
||||
### 3. Workflow Trust Gap
|
||||
|
||||
Implementation undermines operator trust, determinism, or auditability.
|
||||
|
||||
Examples:
|
||||
|
||||
- unclear resume semantics
|
||||
- incomplete status transitions
|
||||
- unexplained UI states
|
||||
|
||||
### 4. Test Blind Spot
|
||||
|
||||
A critical failure mode is not covered.
|
||||
|
||||
Examples:
|
||||
|
||||
- no wrong-tenant test
|
||||
- no unauthorized-action test
|
||||
- no retry or idempotency coverage
|
||||
|
||||
## V. Severity Model
|
||||
|
||||
### Severity 1: Critical
|
||||
|
||||
Immediate risk of:
|
||||
|
||||
- cross-tenant leakage
|
||||
- unauthorized mutation
|
||||
- secret exposure
|
||||
- scope break
|
||||
- severe audit or forensic loss
|
||||
|
||||
### Severity 2: High
|
||||
|
||||
Serious architecture or workflow-trust failure without a proven leak.
|
||||
|
||||
Examples:
|
||||
|
||||
- jobs without reauthorization
|
||||
- unclear ownership
|
||||
- unguarded status transitions
|
||||
- direct provider bypass
|
||||
|
||||
### Severity 3: Medium
|
||||
|
||||
Architectural drift or incomplete hardening that is likely to become a safety or maintenance problem.
|
||||
|
||||
### Severity 4: Low
|
||||
|
||||
Real but non-urgent inconsistency or hardening debt.
|
||||
|
||||
Nothing that directly touches workspace isolation, tenant isolation, RBAC, secrets, or auditability may be rated Low.
|
||||
|
||||
## VI. Expected Audit Output
|
||||
|
||||
For each finding, the auditor must provide:
|
||||
|
||||
1. Title
|
||||
2. Classification
|
||||
3. Severity
|
||||
4. Affected Area
|
||||
5. Evidence
|
||||
6. Why this matters in TenantPilot
|
||||
7. Recommended structural correction
|
||||
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||
|
||||
## VII. Hotfix vs Spec Rule
|
||||
|
||||
### Hotfix
|
||||
|
||||
Use when the correction is local, clear, and restores the constitution without redefining IA or domain semantics.
|
||||
|
||||
### Dedicated spec required
|
||||
|
||||
Use when the finding:
|
||||
|
||||
- affects multiple layers
|
||||
- changes workflow semantics
|
||||
- changes ownership or context modeling
|
||||
- affects run, audit, or report models
|
||||
- affects product IA or operator behavior
|
||||
- requires new invariants or system-wide standardization
|
||||
|
||||
Rule of thumb: if the correct fix is more than adding a guard, it is probably spec-worthy.
|
||||
|
||||
## VIII. Audit Mandate
|
||||
|
||||
The auditor must not only ask whether the code is correct.
|
||||
The auditor must ask:
|
||||
|
||||
- does this violate the workspace-first model?
|
||||
- does this violate capability-first RBAC?
|
||||
- can this undermine operator trust?
|
||||
- can this lose scope or auditability?
|
||||
- does this duplicate domain logic across UI boundaries?
|
||||
- are critical failure modes missing negative tests?
|
||||
|
||||
## IX. Non-Goals of the Audit
|
||||
|
||||
The auditor must not:
|
||||
|
||||
- deliver generic clean-code lectures
|
||||
- inflate trivial style issues
|
||||
- demand arbitrary design patterns without target-model fit
|
||||
- prioritize Laravel or Filament defaults over the product model
|
||||
- frame cosmetic UI issues as architecture failures
|
||||
- recommend local fixes when the deeper issue is systemic
|
||||
@ -22,19 +22,19 @@ class="h-7 w-7 text-primary-500 dark:text-primary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No tenants available</h3>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No active tenants available</h3>
|
||||
<p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400">
|
||||
There are no active tenants in this workspace yet. Add one via onboarding, or switch to a different workspace.
|
||||
There are no selectable active tenants in this workspace. View managed tenants to inspect onboarding or archived records, or switch to a different workspace.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col items-center gap-3">
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="{{ route('admin.onboarding') }}"
|
||||
icon="heroicon-m-plus"
|
||||
href="{{ $workspace ? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]) : route('admin.onboarding') }}"
|
||||
icon="heroicon-m-arrow-right"
|
||||
size="lg"
|
||||
>
|
||||
Add tenant
|
||||
View managed tenants
|
||||
</x-filament::button>
|
||||
|
||||
<a href="{{ route('filament.admin.pages.choose-workspace') }}"
|
||||
|
||||
@ -73,6 +73,9 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
|
||||
{{-- Tenant cards --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}">
|
||||
@foreach ($tenants as $tenant)
|
||||
@php
|
||||
$statusSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::TenantStatus, $tenant->status);
|
||||
@endphp
|
||||
<button
|
||||
type="button"
|
||||
wire:key="tenant-{{ $tenant->id }}"
|
||||
@ -93,12 +96,20 @@ class="h-5 w-5 text-gray-500 group-hover:text-gray-600 dark:text-gray-400 dark:g
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $tenant->name }}
|
||||
</h3>
|
||||
<p class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $tenant->external_id ?? 'No external ID' }}
|
||||
</p>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $tenant->name }}
|
||||
</h3>
|
||||
<p class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $tenant->external_id ?? 'No external ID' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
@ -53,10 +54,11 @@
|
||||
|
||||
$route = request()->route();
|
||||
$routeName = (string) ($route?->getName() ?? '');
|
||||
$pageCategory = TenantPageCategory::fromRequest(request());
|
||||
$tenantQuery = request()->query('tenant');
|
||||
$hasTenantQuery = is_string($tenantQuery) && trim($tenantQuery) !== '';
|
||||
|
||||
$isTenantScopedRoute = $route?->hasParameter('tenant')
|
||||
$isTenantScopedRoute = $pageCategory === TenantPageCategory::TenantBound
|
||||
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
|
||||
|
||||
$lastTenantId = $workspaceContext->lastTenantId(request());
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Tenant Lifecycle, Operability, and Context Semantics Foundation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-14
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/143-tenant-lifecycle-operability-context-semantics/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] Technical governance details are included only where required by repo constitution and action-surface rules
|
||||
- [x] Focused on user value and business and operational needs
|
||||
- [x] Written for product, design, and implementation 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] Implementation and governance details are included only where they clarify in-scope delivery obligations
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation completed on 2026-03-14.
|
||||
- No clarification markers remain.
|
||||
- This repository's spec format intentionally includes technical governance, route, and UI action-surface detail when required by the constitution and implementation boundary.
|
||||
@ -0,0 +1,234 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tenant Lifecycle, Operability, and Context Semantics Admin Contract
|
||||
version: 0.1.0
|
||||
summary: Contract for selector, tenant-bound, onboarding, and canonical workspace record semantics in Spec 143.
|
||||
description: >-
|
||||
This contract documents the expected HTTP-level behavior for the admin-plane routes
|
||||
affected by tenant lifecycle, operability, and remembered tenant context semantics.
|
||||
servers:
|
||||
- url: /admin
|
||||
tags:
|
||||
- name: Tenant Context
|
||||
- name: Tenant Management
|
||||
- name: Onboarding
|
||||
- name: Operations
|
||||
paths:
|
||||
/choose-tenant:
|
||||
get:
|
||||
tags: [Tenant Context]
|
||||
summary: Show normal tenant context choices for the current workspace.
|
||||
description: >-
|
||||
Returns the tenant chooser surface. Only tenants that are eligible for normal active
|
||||
operating context may be shown as selectable choices.
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant chooser rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: No valid workspace context or user is not entitled to the workspace.
|
||||
/select-tenant:
|
||||
post:
|
||||
tags: [Tenant Context]
|
||||
summary: Persist the remembered tenant context for the current workspace.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
required: [tenant_id]
|
||||
properties:
|
||||
tenant_id:
|
||||
type: integer
|
||||
responses:
|
||||
'302':
|
||||
description: Tenant context remembered and browser redirected to the tenant dashboard.
|
||||
'404':
|
||||
description: Tenant not found in current workspace, not entitled, or not eligible for normal active context.
|
||||
'403':
|
||||
description: User is unauthenticated or otherwise not allowed to execute the selection mutation.
|
||||
/clear-tenant-context:
|
||||
post:
|
||||
tags: [Tenant Context]
|
||||
summary: Clear the remembered tenant context for the current workspace.
|
||||
responses:
|
||||
'302':
|
||||
description: Remembered tenant context cleared and browser redirected.
|
||||
/tenants/{tenant}:
|
||||
get:
|
||||
tags: [Tenant Management]
|
||||
summary: View a tenant-bound admin page.
|
||||
description: >-
|
||||
Route legitimacy is based on the route tenant plus workspace and tenant entitlement checks,
|
||||
not on the currently remembered tenant context.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantExternalId'
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant detail page rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: User lacks workspace membership or tenant entitlement, or tenant is not in scope.
|
||||
'403':
|
||||
description: User is a member but lacks a capability for a requested mutation action on this page.
|
||||
/onboarding:
|
||||
get:
|
||||
tags: [Onboarding]
|
||||
summary: View workspace-scoped managed tenant onboarding.
|
||||
responses:
|
||||
'200':
|
||||
description: Onboarding landing or wizard rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: User lacks workspace membership.
|
||||
'403':
|
||||
description: User is a workspace member but lacks onboarding capability.
|
||||
/onboarding/{onboardingDraft}:
|
||||
get:
|
||||
tags: [Onboarding]
|
||||
summary: View or resume a specific onboarding draft.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OnboardingDraftId'
|
||||
responses:
|
||||
'200':
|
||||
description: Onboarding draft rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: User lacks workspace membership or tenant entitlement for the linked draft context.
|
||||
'403':
|
||||
description: User is a workspace member but lacks onboarding capability.
|
||||
/operations:
|
||||
get:
|
||||
tags: [Operations]
|
||||
summary: View the workspace-scoped operations index.
|
||||
description: >-
|
||||
Workspace-scoped monitoring route. Tenant context may act as a default filter, but not as a validity requirement.
|
||||
responses:
|
||||
'200':
|
||||
description: Operations index rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: User lacks workspace membership.
|
||||
/operations/{run}:
|
||||
get:
|
||||
tags: [Operations]
|
||||
summary: View a canonical workspace-owned operation run.
|
||||
description: >-
|
||||
Canonical record viewer. If the user is entitled to the workspace and referenced tenant, the page remains valid even when
|
||||
the remembered active tenant differs from the run's tenant.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OperationRunId'
|
||||
responses:
|
||||
'200':
|
||||
description: Operation run viewer rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: User lacks workspace membership, lacks referenced tenant entitlement, or the run is not in workspace scope.
|
||||
'403':
|
||||
description: User is a workspace or tenant member but lacks the capability resolved for the run type.
|
||||
components:
|
||||
parameters:
|
||||
TenantExternalId:
|
||||
name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Tenant route key external ID.
|
||||
OnboardingDraftId:
|
||||
name: onboardingDraft
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Workspace-scoped onboarding draft identifier.
|
||||
OperationRunId:
|
||||
name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Canonical operation run identifier.
|
||||
schemas:
|
||||
TenantLifecycleState:
|
||||
type: string
|
||||
enum: [draft, onboarding, active, archived]
|
||||
PageCategory:
|
||||
type: string
|
||||
enum:
|
||||
- workspace_scoped
|
||||
- tenant_bound
|
||||
- onboarding_workflow
|
||||
- canonical_workspace_record_viewer
|
||||
AuthorizationOutcome:
|
||||
type: object
|
||||
required: [membership_scope, capability_scope, response_behavior]
|
||||
properties:
|
||||
membership_scope:
|
||||
type: string
|
||||
enum: [workspace, tenant, onboarding_draft, canonical_record]
|
||||
capability_scope:
|
||||
type: string
|
||||
response_behavior:
|
||||
type: string
|
||||
enum: [allow, deny_as_not_found, forbid]
|
||||
TenantOperabilityDecision:
|
||||
type: object
|
||||
required:
|
||||
- lifecycle
|
||||
- can_view_tenant_surface
|
||||
- can_select_as_context
|
||||
- can_operate
|
||||
- can_archive
|
||||
- can_restore
|
||||
- can_resume_onboarding
|
||||
- can_reference_in_workspace_monitoring
|
||||
properties:
|
||||
lifecycle:
|
||||
$ref: '#/components/schemas/TenantLifecycleState'
|
||||
can_view_tenant_surface:
|
||||
type: boolean
|
||||
can_select_as_context:
|
||||
type: boolean
|
||||
can_operate:
|
||||
type: boolean
|
||||
can_archive:
|
||||
type: boolean
|
||||
can_restore:
|
||||
type: boolean
|
||||
can_resume_onboarding:
|
||||
type: boolean
|
||||
can_reference_in_workspace_monitoring:
|
||||
type: boolean
|
||||
CanonicalViewerContextMismatch:
|
||||
type: object
|
||||
required: [selected_tenant_id, referenced_tenant_id, behavior]
|
||||
properties:
|
||||
selected_tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
referenced_tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
behavior:
|
||||
type: string
|
||||
enum: [ignore_for_legitimacy, show_context_banner]
|
||||
@ -0,0 +1,180 @@
|
||||
# Data Model: Tenant Lifecycle, Operability, and Context Semantics Foundation
|
||||
|
||||
## Core Entities
|
||||
|
||||
### Workspace
|
||||
|
||||
- Purpose: Primary ownership, isolation, and session boundary.
|
||||
- Existing source: `App\Models\Workspace`.
|
||||
- Key relationships:
|
||||
- has many tenants
|
||||
- has many onboarding workflow records
|
||||
- has many canonical operation runs
|
||||
- Validation and invariants:
|
||||
- Workspace membership is required before any tenant or canonical record is revealed.
|
||||
- Workspace-owned canonical viewers may exist without tenant context in the URL.
|
||||
|
||||
### Tenant
|
||||
|
||||
- Purpose: Durable workspace-owned tenant record.
|
||||
- Existing source: `App\Models\Tenant`.
|
||||
- Canonical fields relevant to this feature:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `external_id`
|
||||
- `name`
|
||||
- `status`
|
||||
- `deleted_at`
|
||||
- `is_current`
|
||||
- Canonical lifecycle states:
|
||||
- `draft`
|
||||
- `onboarding`
|
||||
- `active`
|
||||
- `archived`
|
||||
- Relationships:
|
||||
- belongs to workspace
|
||||
- has many memberships
|
||||
- may be linked from onboarding workflow records
|
||||
- may be referenced by operation runs
|
||||
- Validation and invariants:
|
||||
- Tenant lifecycle is the durable domain status, not the full workflow progression state.
|
||||
- Only `active` tenants may become normal tenant context.
|
||||
- Archived tenants remain retained for auditability and controlled restoration.
|
||||
|
||||
### TenantOnboardingSession
|
||||
|
||||
- Purpose: Workspace-scoped onboarding workflow record.
|
||||
- Existing source: `App\Models\TenantOnboardingSession`.
|
||||
- Key fields relevant to this feature:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `lifecycle_state`
|
||||
- `current_checkpoint`
|
||||
- `last_completed_checkpoint`
|
||||
- `state`
|
||||
- `version`
|
||||
- `completed_at`
|
||||
- `cancelled_at`
|
||||
- Relationships:
|
||||
- belongs to workspace
|
||||
- optionally belongs to tenant
|
||||
- Validation and invariants:
|
||||
- Owns onboarding progression, resumability, and conflict-safe mutation.
|
||||
- Does not replace tenant lifecycle.
|
||||
- Must enforce workspace entitlement, and tenant entitlement once a tenant link exists.
|
||||
|
||||
### OperationRun
|
||||
|
||||
- Purpose: Canonical workspace-owned record for long-running or operationally relevant work.
|
||||
- Existing source: `App\Models\OperationRun`.
|
||||
- Key fields relevant to this feature:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id` nullable
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `context`
|
||||
- `summary_counts`
|
||||
- `failure_summary`
|
||||
- Relationships:
|
||||
- belongs to workspace
|
||||
- optionally belongs to tenant
|
||||
- Validation and invariants:
|
||||
- A canonical viewer authorizes from the run, workspace, and entitlement checks.
|
||||
- Tenant mismatch against remembered context must not invalidate the run.
|
||||
- Status and outcome transitions remain service-owned.
|
||||
|
||||
### RememberedTenantContext
|
||||
|
||||
- Purpose: Operator preference state that remembers the last tenant selection per workspace.
|
||||
- Existing implementation surfaces:
|
||||
- `App\Support\Workspaces\WorkspaceContext`
|
||||
- `App\Http\Controllers\SelectTenantController`
|
||||
- `App\Http\Controllers\ClearTenantContextController`
|
||||
- `App\Filament\Pages\ChooseTenant`
|
||||
- Data shape:
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- Validation and invariants:
|
||||
- This is not an authorization primitive.
|
||||
- It may prefilter workspace pages and drive convenience redirects.
|
||||
- It must be cleared or ignored when the selected tenant is no longer eligible for normal active context.
|
||||
|
||||
## Derived Domain Concepts
|
||||
|
||||
### TenantOperabilityDecision
|
||||
|
||||
- Purpose: Derived policy result for what the operator may do with a tenant.
|
||||
- Inputs:
|
||||
- actor
|
||||
- workspace
|
||||
- tenant
|
||||
- page category
|
||||
- requested operation
|
||||
- Outputs:
|
||||
- `can_view_tenant_surface`
|
||||
- `can_select_as_context`
|
||||
- `can_operate`
|
||||
- `can_archive`
|
||||
- `can_restore`
|
||||
- `can_resume_onboarding`
|
||||
- `can_reference_in_workspace_monitoring`
|
||||
|
||||
### PageCategory
|
||||
|
||||
- Purpose: Normalize route semantics by page type.
|
||||
- Canonical values:
|
||||
- `workspace_scoped`
|
||||
- `tenant_bound`
|
||||
- `onboarding_workflow`
|
||||
- `canonical_workspace_record_viewer`
|
||||
- Invariants:
|
||||
- Every in-scope route must map to one category.
|
||||
- Route legitimacy rules are defined by category, not by remembered tenant state.
|
||||
|
||||
### LifecyclePresentationSpec
|
||||
|
||||
- Purpose: Central mapping from tenant lifecycle to operator-facing label, badge color, icon, and allowed explanatory copy.
|
||||
- Existing extension point:
|
||||
- `BadgeCatalog`
|
||||
- `BadgeDomain::TenantStatus`
|
||||
- Invariants:
|
||||
- All canonical lifecycle values must map explicitly.
|
||||
- No valid lifecycle may render as `Unknown`.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Tenant lifecycle transitions
|
||||
|
||||
- `draft -> onboarding`
|
||||
- Trigger: onboarding has advanced enough that the tenant exists as an in-progress managed tenant.
|
||||
- `onboarding -> active`
|
||||
- Trigger: onboarding completion or activation flow defined by follow-up implementation specs.
|
||||
- `active -> archived`
|
||||
- Trigger: explicit archive action with authorization, confirmation, and audit logging.
|
||||
- `archived -> restored_state`
|
||||
- Trigger: explicit restore action defined by follow-up specs.
|
||||
- Note: current model restore behavior returns to `active`, but this foundation leaves restored-state semantics to follow-up work.
|
||||
|
||||
### Onboarding workflow transitions
|
||||
|
||||
- Stay owned by `TenantOnboardingSession` lifecycle and checkpoint services.
|
||||
- Must not be conflated with tenant lifecycle labels or selector eligibility.
|
||||
|
||||
## Route-to-entity mapping
|
||||
|
||||
- `/admin`, `/admin/choose-workspace`, `/admin/operations`: workspace plus remembered tenant preference, optionally filtered by tenant.
|
||||
- `/admin/choose-tenant`: workspace plus eligible tenant collection.
|
||||
- `/admin/tenants`, `/admin/tenants/{tenant}`: workspace plus route tenant.
|
||||
- `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}`: workspace plus onboarding workflow record, optionally linked tenant.
|
||||
- `/admin/operations/{run}`: workspace plus canonical operation run, optionally linked tenant.
|
||||
|
||||
## Test focus derived from the model
|
||||
|
||||
- Selector eligibility tests for all tenant lifecycle states.
|
||||
- Canonical run viewer tests for mismatched selected tenant versus referenced tenant.
|
||||
- Badge coverage tests for every canonical tenant lifecycle state.
|
||||
- Authorization tests asserting 404 for non-members and 403 for members missing capability.
|
||||
- Onboarding workflow tests proving onboarding drafts remain visible and resumable without becoming active tenant context.
|
||||
198
specs/143-tenant-lifecycle-operability-context-semantics/plan.md
Normal file
198
specs/143-tenant-lifecycle-operability-context-semantics/plan.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Implementation Plan: Tenant Lifecycle, Operability, and Context Semantics Foundation
|
||||
|
||||
**Branch**: `143-tenant-lifecycle-operability-context-semantics` | **Date**: 2026-03-14 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/143-tenant-lifecycle-operability-context-semantics/spec.md`
|
||||
**Input**: Feature specification from `/specs/143-tenant-lifecycle-operability-context-semantics/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Define a single foundation for tenant lifecycle, tenant operability, and tenant context semantics across workspace-scoped admin surfaces, tenant-bound pages, onboarding flows, and canonical workspace record viewers. The implementation approach is to formalize lifecycle and operability decisions behind central domain services and presentation mappings, then apply those decisions in the first rollout slice to selector eligibility, tenant management actions, onboarding visibility, tenant-safe global search, lifecycle audit coverage, and canonical operation-run viewing without letting remembered tenant context determine route legitimacy.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables
|
||||
**Testing**: Pest feature, unit, and browser tests run through Laravel Sail
|
||||
**Target Platform**: Laravel web application served through Filament admin panels
|
||||
**Project Type**: Web application with server-rendered Filament and Livewire surfaces
|
||||
**Performance Goals**: Preserve DB-only rendering for Monitoring and canonical run viewers, eliminate false 404 outcomes caused by remembered tenant mismatch, and keep selector/context transitions request-bounded without external calls during page render
|
||||
**Constraints**: Workspace isolation and tenant isolation are non-negotiable; admin-plane canonical viewers must preserve 404 vs 403 semantics; no new Microsoft Graph calls are introduced by this foundation; badge semantics must remain centralized; destructive actions continue to require explicit confirmation
|
||||
**Scale/Scope**: Cross-cutting foundation across 8 primary admin routes, 5 core domain entities, multiple Filament pages/resources, and existing authorization, badge, and workspace-context helpers
|
||||
|
||||
Additional implementation facts:
|
||||
|
||||
- Livewire v4.0+ compliance is already required and preserved because this repo runs Filament v5 on Livewire v4.
|
||||
- Filament panel providers are registered in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/bootstrap/providers.php`.
|
||||
- Relevant global-search posture in current scope:
|
||||
- `TenantResource` is globally searchable only if kept through its existing resource view/edit surface; any follow-up global-search changes must preserve a View/Edit page.
|
||||
- `OperationRunResource` has global search disabled already.
|
||||
- The onboarding and workspace landing surfaces are pages, not searchable resources.
|
||||
- This implementation must add explicit regression coverage to ensure lifecycle and operability rules do not leak ineligible tenants into global search results.
|
||||
- Lifecycle mutations introduced or clarified by this feature must emit explicit workspace audit entries through the existing audit layer.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: clarify what is “last observed” vs snapshots/backups
|
||||
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||
- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy
|
||||
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
|
||||
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
|
||||
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
|
||||
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
|
||||
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
|
||||
Gate status before Phase 0 research: PASS
|
||||
|
||||
- No new Graph contract work is required for this foundation because it does not add outbound Graph calls.
|
||||
- The feature affects admin-plane authorization semantics and therefore must preserve canonical capability registry usage, deny-as-not-found boundaries, and central policy enforcement.
|
||||
- The feature affects Filament tenant and operations surfaces, so this implementation slice must satisfy the Action Surface Contract and UX-001 for every modified surface; later follow-up specs may extend the same rules to additional screens.
|
||||
- The feature affects canonical operation viewers but does not change `OperationRun` lifecycle ownership; any follow-up changes must continue using `OperationRunService` for status and outcome transitions.
|
||||
- Badge centralization is already available through `BadgeCatalog` and must be extended rather than bypassed for tenant lifecycle semantics.
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/143-tenant-lifecycle-operability-context-semantics/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── checklists/
|
||||
│ └── requirements.md # Spec quality checklist
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ ├── ChooseTenant.php
|
||||
│ │ ├── Operations/TenantlessOperationRunViewer.php
|
||||
│ │ └── Workspaces/
|
||||
│ │ ├── ManagedTenantOnboardingWizard.php
|
||||
│ │ └── ManagedTenantsLanding.php
|
||||
│ ├── Resources/
|
||||
│ │ ├── OperationRunResource.php
|
||||
│ │ └── TenantResource.php
|
||||
│ └── Concerns/
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ │ ├── SelectTenantController.php
|
||||
│ │ ├── ClearTenantContextController.php
|
||||
│ │ └── SwitchWorkspaceController.php
|
||||
│ └── Middleware/
|
||||
├── Models/
|
||||
│ ├── Tenant.php
|
||||
│ ├── TenantOnboardingSession.php
|
||||
│ └── OperationRun.php
|
||||
├── Policies/
|
||||
│ ├── OperationRunPolicy.php
|
||||
│ └── TenantOnboardingSessionPolicy.php
|
||||
├── Services/
|
||||
│ ├── Audit/
|
||||
│ ├── OperationRunService.php
|
||||
│ ├── Tenants/
|
||||
│ └── Onboarding/
|
||||
└── Support/
|
||||
├── Audit/
|
||||
├── Auth/
|
||||
├── Badges/
|
||||
├── OperateHub/
|
||||
├── Tenants/
|
||||
└── Workspaces/
|
||||
|
||||
resources/views/
|
||||
routes/web.php
|
||||
bootstrap/providers.php
|
||||
|
||||
tests/
|
||||
├── Browser/
|
||||
├── Feature/
|
||||
│ ├── Audit/
|
||||
│ ├── Auth/
|
||||
│ ├── Filament/
|
||||
│ ├── Monitoring/
|
||||
│ ├── Onboarding/
|
||||
│ ├── Operations/
|
||||
│ ├── OpsUx/
|
||||
│ ├── Rbac/
|
||||
│ ├── Spec085/
|
||||
│ └── TenantRBAC/
|
||||
└── Unit/
|
||||
├── Tenants/
|
||||
└── Onboarding/
|
||||
```
|
||||
|
||||
Additional targeted tests may also live in existing focused suites such as `tests/Feature/Guards/` when the regression is cross-cutting rather than surface-specific.
|
||||
|
||||
**Structure Decision**: Use the existing Laravel single-project structure. The implementation is cross-cutting but remains inside current `app/`, `routes/`, `resources/views/`, and `tests/` directories. No new top-level source folders are needed, but this feature may introduce focused `Tenants` support/service namespaces and matching `tests/Unit/Tenants` coverage within the existing tree.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| None | N/A | N/A |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
- Consolidate lifecycle semantics around the existing `Tenant` status values instead of inventing a parallel state source.
|
||||
- Formalize selector eligibility and remembered-context behavior around workspace context plus operability rules.
|
||||
- Decouple canonical operation-run viewer validity from active tenant mismatch while preserving existing `OperationRunPolicy` tenant-entitlement checks.
|
||||
- Extend centralized badge semantics for tenant lifecycle presentation rather than adding ad hoc UI mappings.
|
||||
- Preserve separation between tenant lifecycle and onboarding draft lifecycle by layering rules across `Tenant` and `TenantOnboardingSession` instead of merging the models.
|
||||
|
||||
Research output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/143-tenant-lifecycle-operability-context-semantics/research.md`
|
||||
|
||||
## Phase 1 Design & Contracts
|
||||
|
||||
- Define the domain model and derived concepts for lifecycle, operability, context eligibility, canonical viewers, and remembered tenant preference.
|
||||
- Record the affected route contracts for selector, tenant-bound, onboarding, and canonical record viewer semantics.
|
||||
- Provide a quickstart validation flow that follow-up implementation specs can use for acceptance testing and regression targeting.
|
||||
- Update agent context after design artifacts are generated.
|
||||
|
||||
Design outputs:
|
||||
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/143-tenant-lifecycle-operability-context-semantics/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/143-tenant-lifecycle-operability-context-semantics/contracts/admin-tenant-context-foundation.openapi.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/143-tenant-lifecycle-operability-context-semantics/quickstart.md`
|
||||
|
||||
## Post-Design Constitution Check
|
||||
|
||||
Gate status after Phase 1 design: PASS
|
||||
|
||||
- Livewire v4.0+ compliance remains explicit and unchanged.
|
||||
- Filament provider registration remains correctly anchored in `bootstrap/providers.php`.
|
||||
- No affected design artifact introduces direct Graph calls, new raw capability strings, or non-central badge mappings.
|
||||
- Canonical record viewer design preserves workspace membership + tenant entitlement checks and removes remembered-tenant mismatch as a source of false 404.
|
||||
- Destructive lifecycle actions remain explicitly confirmation-gated in follow-up implementation work.
|
||||
|
||||
## Implementation Sequencing
|
||||
|
||||
1. Introduce central lifecycle and operability abstractions around `Tenant` status and page categories.
|
||||
2. Refactor selector and remembered-context resolution to consume operability decisions instead of raw `status = active` checks scattered across pages and controllers.
|
||||
3. Refactor canonical workspace record viewers, especially `TenantlessOperationRunViewer`, to authorize by record and entitlement rather than active tenant equality.
|
||||
4. Centralize lifecycle presentation and action-label semantics on tenant management surfaces.
|
||||
5. Add or update Pest feature, unit, and browser tests for canonical viewer access, selector eligibility, badge coverage, tenant-safe global search, lifecycle audit logging, and authorization semantics.
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
# Quickstart: Tenant Lifecycle, Operability, and Context Semantics Foundation
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this guide to validate follow-up implementation work derived from Spec 143.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Laravel Sail services are running.
|
||||
- An admin-plane user exists with workspace membership.
|
||||
- At least one workspace exists with tenants covering these lifecycle states:
|
||||
- `draft`
|
||||
- `onboarding`
|
||||
- `active`
|
||||
- `archived`
|
||||
- At least one authorized `OperationRun` exists for a tenant that is not the currently selected tenant.
|
||||
- At least one resumable `TenantOnboardingSession` exists for an onboarding tenant.
|
||||
|
||||
## Validation flow
|
||||
|
||||
### 1. Validate selector eligibility
|
||||
|
||||
- Open `/admin/choose-tenant`.
|
||||
- Confirm only `active` tenants are selectable as normal tenant context.
|
||||
- Confirm `draft`, `onboarding`, and `archived` tenants are not selectable in the standard tenant chooser.
|
||||
|
||||
Expected result:
|
||||
|
||||
- The chooser represents normal operating context only.
|
||||
- No invalid tenant selection path is available.
|
||||
|
||||
### 2. Validate management and onboarding visibility
|
||||
|
||||
- Open `/admin/tenants` and inspect lifecycle labels and actions.
|
||||
- Open `/admin/onboarding` and any resumable onboarding draft.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Onboarding and draft tenants remain visible in the correct surfaces.
|
||||
- Archived tenants remain visible only where administrative or audit semantics justify them.
|
||||
- Lifecycle-related actions are vocabulary-correct: `Archive`, `Restore`, `Resume onboarding`.
|
||||
|
||||
### 3. Validate canonical operation viewer behavior
|
||||
|
||||
- Select tenant B as the remembered tenant context.
|
||||
- Open `/admin/operations/{run}` for a run linked to tenant A.
|
||||
|
||||
Expected result:
|
||||
|
||||
- The run remains visible if workspace membership, tenant entitlement, and capability checks pass.
|
||||
- The page handles the mismatch explicitly in UX if implemented, but does not return a false 404.
|
||||
|
||||
### 4. Validate tenant-bound route behavior
|
||||
|
||||
- Open `/admin/tenants/{tenant}` for an onboarding tenant that the user is entitled to.
|
||||
- Repeat for an archived tenant if allowed by the follow-up implementation.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Route legitimacy comes from the route tenant plus entitlement checks.
|
||||
- Action availability changes with lifecycle, but route validity does not depend on current header tenant selection.
|
||||
|
||||
### 5. Validate authorization semantics
|
||||
|
||||
- Attempt the same tenant and operation-run routes as:
|
||||
- a non-member user
|
||||
- a workspace member without the required capability
|
||||
|
||||
Expected result:
|
||||
|
||||
- Non-member or non-entitled access resolves as 404.
|
||||
- Member without capability resolves as 403 for execution attempts.
|
||||
|
||||
### 6. Validate status presentation
|
||||
|
||||
- Inspect tenant lifecycle badges wherever tenant status appears.
|
||||
|
||||
Expected result:
|
||||
|
||||
- `draft`, `onboarding`, `active`, and `archived` all render explicit, centralized status presentation.
|
||||
- No valid lifecycle renders as `Unknown`.
|
||||
|
||||
## Suggested focused test targets
|
||||
|
||||
- `tests/Feature/Auth/TenantChooserSelectionTest.php`
|
||||
- `tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`
|
||||
- `tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php`
|
||||
- `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
|
||||
- `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
- `tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php`
|
||||
- `tests/Feature/Badges/TenantStatusBadgeTest.php`
|
||||
- `tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
|
||||
- `tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php`
|
||||
|
||||
## Filament and deployment notes
|
||||
|
||||
- Livewire v4.0+ compliance remains required because this feature touches Filament v5 surfaces.
|
||||
- Filament providers remain registered in `bootstrap/providers.php`.
|
||||
- This foundation adds no new assets, so there is no new `filament:assets` work beyond the project’s existing deploy process.
|
||||
@ -0,0 +1,65 @@
|
||||
# Research: Tenant Lifecycle, Operability, and Context Semantics Foundation
|
||||
|
||||
## Decision 1: Reuse the existing `Tenant` lifecycle states as the canonical domain source
|
||||
|
||||
- Decision: Keep `draft`, `onboarding`, `active`, and `archived` on `App\Models\Tenant` as the single canonical lifecycle vocabulary for tenant records, and build operability and context rules on top of that rather than introducing a second lifecycle store.
|
||||
- Rationale: `Tenant` already defines the lifecycle constants, soft-delete behavior, and current-selection safety (`makeCurrent()` rejects non-active tenants). The inconsistency is not missing states; it is inconsistent interpretation of those states across pages and helpers.
|
||||
- Alternatives considered:
|
||||
- Add a second lifecycle table or projection. Rejected because it would duplicate the same tenant-state truth and increase reconciliation complexity.
|
||||
- Derive tenant lifecycle entirely from onboarding session lifecycle. Rejected because onboarding drafts are workflow records and do not replace the durable tenant record.
|
||||
|
||||
## Decision 2: Model operability separately from lifecycle
|
||||
|
||||
- Decision: Introduce a central operability layer that answers viewability, selector eligibility, action eligibility, onboarding resumability, and canonical monitoring referenceability independently of raw lifecycle.
|
||||
- Rationale: Current code uses raw `where('status', 'active')`, `isActive()`, or soft-delete checks in selectors, controllers, and actions. That pattern conflates lifecycle with every UX and authorization choice.
|
||||
- Alternatives considered:
|
||||
- Continue using direct `status === 'active'` checks with better comments. Rejected because it does not prevent future divergence.
|
||||
- Encode every decision directly into policies. Rejected because selector visibility, badge presentation, and page categorization are not purely authorization concerns.
|
||||
|
||||
## Decision 3: Treat remembered tenant context as preference state, not route legitimacy
|
||||
|
||||
- Decision: Keep remembered tenant state in workspace context helpers, but treat it as filter and navigation preference only. Canonical workspace-owned record viewers must authorize by record and entitlement, not by active tenant equality.
|
||||
- Rationale: `TenantlessOperationRunViewer::mount()` currently aborts 404 when the active tenant differs from the run tenant even after `OperationRunPolicy` already authorizes the user. That is the concrete trust failure this foundation must eliminate.
|
||||
- Alternatives considered:
|
||||
- Keep the viewer mismatch 404 and document it. Rejected because it makes a valid record appear missing and violates the spec’s canonical-record rule.
|
||||
- Automatically switch the selected tenant when viewing a canonical record. Rejected because it mutates operator context as a side effect of inspection.
|
||||
|
||||
## Decision 4: Keep onboarding workflow state distinct from tenant lifecycle
|
||||
|
||||
- Decision: Preserve `TenantOnboardingSession` as the owner of workflow progression, resumability, and checkpoint semantics, while `Tenant` owns lifecycle semantics for the durable tenant record.
|
||||
- Rationale: The codebase already uses `OnboardingLifecycleService`, `OnboardingDraftResolver`, and `TenantOnboardingSessionPolicy` to manage workflow-specific state. Folding that into tenant lifecycle would blur durable domain state with wizard progression.
|
||||
- Alternatives considered:
|
||||
- Collapse onboarding lifecycle into tenant status. Rejected because checkpoint-level progress, resumability, and versioning belong to the workflow record.
|
||||
- Hide onboarding tenants until activation. Rejected because operators need to resume, inspect, and audit onboarding work before activation.
|
||||
|
||||
## Decision 5: Extend centralized badge semantics instead of page-local mappings
|
||||
|
||||
- Decision: Use `BadgeCatalog` and `BadgeDomain` as the only badge semantics extension point for tenant lifecycle presentation, and add exhaustive coverage tests there.
|
||||
- Rationale: Tenant status in `TenantResource` already uses `BadgeRenderer::label/color/icon(BadgeDomain::TenantStatus)`. The fix for `Unknown` rendering belongs in the badge domain mapping, not in per-page string conditionals.
|
||||
- Alternatives considered:
|
||||
- Patch each affected page with its own match statement. Rejected because it recreates drift.
|
||||
- Render raw status strings without badge mapping. Rejected because it breaks BADGE-001 and current UI consistency.
|
||||
|
||||
## Decision 6: Anchor authorization in existing policies and capability registries
|
||||
|
||||
- Decision: Follow-up implementation work should keep server-side enforcement in `OperationRunPolicy`, tenant policies, onboarding policies, `CapabilityResolver`, `WorkspaceCapabilityResolver`, and `Capabilities`, with any new lifecycle-aware decisions delegated through central helpers rather than raw capability strings.
|
||||
- Rationale: The repo already has a strong split between 404 membership failures and 403 capability failures. The problem is contextual misuse, not absence of enforcement primitives.
|
||||
- Alternatives considered:
|
||||
- Introduce route-local authorization closures. Rejected because it would increase duplication and drift.
|
||||
- Rely on Filament visibility or disabling state alone. Rejected because the constitution explicitly forbids using UI as the security boundary.
|
||||
|
||||
## Decision 7: Plan follow-up implementation around existing test clusters
|
||||
|
||||
- Decision: Target follow-up validation through existing Pest suites in `tests/Feature/Auth`, `tests/Feature/Monitoring`, `tests/Feature/Onboarding`, `tests/Feature/Filament`, `tests/Feature/Rbac`, `tests/Feature/TenantRBAC`, plus focused unit coverage for onboarding and badge semantics.
|
||||
- Rationale: The repo already has dedicated tests for tenant chooser selection, tenant switcher scope, archived tenant access, onboarding authorization, canonical monitoring behavior, and operation-run viewing.
|
||||
- Alternatives considered:
|
||||
- Create a new isolated test suite just for lifecycle semantics. Rejected because the behavior is cross-cutting and should strengthen the existing regression clusters.
|
||||
|
||||
## Filament and panel notes
|
||||
|
||||
- Filament v5 runs on Livewire v4 in this repository.
|
||||
- Panel providers are registered in `bootstrap/providers.php`, which already contains `AdminPanelProvider`, `TenantPanelProvider`, and `SystemPanelProvider`.
|
||||
- Relevant searchable-resource posture in current scope:
|
||||
- `TenantResource` has a resource view surface and can remain globally searchable if desired.
|
||||
- `OperationRunResource` has global search disabled.
|
||||
- Workspace landing and onboarding wizard are pages, not resources.
|
||||
208
specs/143-tenant-lifecycle-operability-context-semantics/spec.md
Normal file
208
specs/143-tenant-lifecycle-operability-context-semantics/spec.md
Normal file
@ -0,0 +1,208 @@
|
||||
# Feature Specification: Tenant Lifecycle, Operability, and Context Semantics Foundation
|
||||
|
||||
**Feature Branch**: `143-tenant-lifecycle-operability-context-semantics`
|
||||
**Created**: 2026-03-14
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Structural review of tenant lifecycle inconsistencies across onboarding, tenant context selection, operation viewing, action availability, status rendering, and authorization semantics."
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant-bound + canonical workspace record viewers
|
||||
- **Primary Routes**:
|
||||
- `/admin`
|
||||
- `/admin/choose-workspace`
|
||||
- `/admin/choose-tenant`
|
||||
- `/admin/tenants`
|
||||
- `/admin/tenants/{tenant}`
|
||||
- `/admin/onboarding`
|
||||
- `/admin/onboarding/{onboardingDraft}`
|
||||
- `/admin/operations`
|
||||
- `/admin/operations/{run}`
|
||||
- **Data Ownership**:
|
||||
- Workspaces remain the primary operating boundary.
|
||||
- Tenants remain workspace-owned records.
|
||||
- Onboarding drafts and sessions remain workspace-scoped workflow records that may link to a tenant.
|
||||
- Operation runs remain canonical workspace-owned records that may reference a tenant.
|
||||
- This feature does not introduce cross-workspace sharing or change underlying ownership boundaries.
|
||||
- **RBAC**:
|
||||
- Authorization planes involved: tenant/admin `/admin` and tenant-context behavior within the admin plane.
|
||||
- Workspace non-members and users without tenant entitlement for a tenant-bound record receive deny-as-not-found semantics.
|
||||
- Workspace members lacking the required capability for an otherwise visible action receive forbidden semantics.
|
||||
- Authorization decisions must come from workspace membership, tenant entitlement, capability policy checks, and page semantics, not from remembered tenant context.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Workspace-scoped indexes may prefilter to the currently selected tenant as a convenience, but the filter is optional state and must never determine whether a canonical workspace-owned record is legitimate.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical record viewers must verify workspace ownership of the record, tenant reference ownership where applicable, and actor entitlement to the workspace and referenced tenant before rendering any tenant-linked details.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Trust Canonical Record Views (Priority: P1)
|
||||
|
||||
As a workspace operator, I need deep links to canonical workspace records such as operation runs to remain valid even when my remembered tenant context points at another tenant, so that I can investigate work reliably without false not-found errors.
|
||||
|
||||
**Why this priority**: Broken run viewers undermine operator trust fastest because they make valid records appear missing and block investigation during real incidents.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening a valid operation run while a different tenant is selected and confirming the page still renders the run, authorizes correctly, and explains any tenant-context mismatch without breaking navigation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is authorized for a workspace operation run linked to tenant A, **When** the user opens the run while tenant B is selected in the header, **Then** the run viewer remains accessible and the mismatch is handled as context information rather than record invalidity.
|
||||
2. **Given** a user is not entitled to the workspace or linked tenant of a canonical record, **When** the user opens the deep link, **Then** the system responds with deny-as-not-found behavior and does not leak tenant identity.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Select Only Operable Tenants (Priority: P2)
|
||||
|
||||
As a workspace operator, I need the standard tenant selector and choose-tenant flow to show only normal operating tenants, so that selecting a tenant never puts me into an invalid or misleading operating context.
|
||||
|
||||
**Why this priority**: Tenant selection is a top-level navigation action. If the selector offers ineligible tenants, every downstream page becomes harder to reason about.
|
||||
|
||||
**Independent Test**: Can be fully tested by preparing tenants in `draft`, `onboarding`, `active`, and `archived` states and verifying that only `active` tenants appear as selectable normal context while non-active tenants remain visible in the correct management or onboarding surfaces.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace contains tenants in all canonical lifecycle states, **When** the user opens the normal tenant selector, **Then** only `active` tenants are available for selection.
|
||||
2. **Given** a workspace contains onboarding and archived tenants, **When** the user visits onboarding or administrative management surfaces, **Then** those tenants remain visible with lifecycle-appropriate affordances instead of disappearing from the product.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Understand Lifecycle and Actions Clearly (Priority: P3)
|
||||
|
||||
As a workspace operator, I need tenant statuses and lifecycle actions to be labeled consistently with actual domain behavior, so that I can tell whether a tenant is operable, resumable, archivable, or audit-only without interpreting hidden rules.
|
||||
|
||||
**Why this priority**: Clear status and action language prevents operator error and creates a stable base for future governance, audit, and customer-facing read-only features.
|
||||
|
||||
**Independent Test**: Can be fully tested by reviewing in-scope tenant surfaces and confirming that every valid lifecycle renders explicitly, that archive-like actions are labeled as archive, and that ineligible actions are not shown for the current lifecycle.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant is in `onboarding`, **When** the user views management surfaces, **Then** the tenant is labeled as onboarding, may expose resume-onboarding behavior if authorized, and is not presented as a normal active tenant context.
|
||||
2. **Given** a tenant is in `archived`, **When** the user views tenant management surfaces, **Then** the tenant is clearly labeled archived, excluded from normal active selection, and limited to audit or restore-oriented actions when authorized.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a remembered tenant was previously `active` but has since become `archived` or `onboarding`? The remembered context must be cleared or ignored for active-context behavior without breaking workspace-scoped pages.
|
||||
- How does the system handle a canonical workspace record that references an archived tenant? The record remains viewable if authorization allows it, and the tenant reference is shown with archived semantics instead of causing a false not-found.
|
||||
- What happens when a canonical workspace record has no tenant reference at all? The record remains valid within its workspace scope and must not depend on tenant context.
|
||||
- How does the system handle a tenant lifecycle transition while an operator is already on a tenant-bound page? The page remains governed by route record legitimacy, but actions and status presentation must refresh to the new lifecycle semantics.
|
||||
- What happens when onboarding workflow data exists for a tenant that is no longer eligible for onboarding resume? The tenant remains viewable in management surfaces, but resume affordances are suppressed unless operability rules explicitly allow them.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls, new outbound write behavior, or new queued work. It defines the semantic rules that this implementation slice and any later follow-up implementation specs must apply when lifecycle-driven actions, onboarding continuation, or canonical record viewing touch audit, authorization, or run observability.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature does not create a new `OperationRun` type. It does define that operation-run viewers are canonical workspace record viewers whose legitimacy must be based on the run, workspace, and entitlement checks rather than remembered tenant context. This implementation slice and any later follow-up implementation that reuses an `OperationRun` must preserve the existing three-surface feedback contract and service-owned status transitions.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature changes authorization semantics in the admin plane by separating selector preference from authorization truth. It requires deny-as-not-found behavior for users lacking workspace membership or tenant entitlement, forbidden behavior for users who are members but lack the required capability, server-side enforcement through central policies or authorization services, canonical capability registry usage instead of raw strings, non-member-safe global search behavior, and confirmation on destructive actions introduced by follow-up specs. Validation must include at least one positive and one negative authorization scenario for canonical viewers and tenant-bound surfaces.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** This feature does not alter authentication handshakes or allow synchronous login-path behavior to substitute for Monitoring or Operations semantics.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature requires tenant lifecycle badges to be centrally defined and exhaustive so that `draft`, `onboarding`, `active`, and `archived` never fall through to ad hoc `Unknown` rendering.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** This feature standardizes operator vocabulary for lifecycle actions and views. Target objects are tenant records and canonical workspace records. Primary verbs are `Archive`, `Restore`, `Resume onboarding`, and `View`. Source disambiguation is only added when needed to distinguish workspace-level record viewers from tenant context. The same lifecycle language must be preserved across table actions, modals, notifications, run viewers, and audit prose.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature changes behavior expectations for Filament tenant-management and operations surfaces. The matrix below defines the exact action inventories for the surfaces changed in this implementation slice. Later follow-up specs may extend the same rules to additional screens, but the surfaces listed below are fully in scope now.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature defines information architecture semantics rather than a full layout redesign. The current implementation slice must preserve UX-001 on each affected Filament page while applying the lifecycle, operability, and context rules defined here.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST treat `draft`, `onboarding`, `active`, and `archived` as the only canonical tenant lifecycle states within this feature scope.
|
||||
- **FR-002**: The system MUST define tenant lifecycle as a domain concept separate from tenant operability and separate from tenant context visibility.
|
||||
- **FR-003**: The system MUST establish a central operability model that decides whether a tenant is viewable, selectable as tenant context, operable for a given action, archivable, restorable, resumable for onboarding, and referenceable in workspace monitoring.
|
||||
- **FR-004**: The standard tenant selector and choose-tenant flow MUST allow only `active` tenants to become the normal remembered tenant context.
|
||||
- **FR-005**: `draft` and `onboarding` tenants MUST remain visible in onboarding and administrative management surfaces where the operator needs to create, resume, inspect, or govern onboarding work.
|
||||
- **FR-006**: `archived` tenants MUST be excluded from normal tenant selection and shown only in administrative, audit, recovery, or other explicitly allowed surfaces.
|
||||
- **FR-007**: The remembered tenant context MUST be treated as convenience state rather than an authorization primitive or truth source for route legitimacy.
|
||||
- **FR-008**: When a remembered tenant is no longer eligible for normal active context, the system MUST clear or ignore that remembered value for active-context behavior without breaking workspace-scoped routes.
|
||||
- **FR-009**: Canonical workspace-level record viewers, including operation runs, MUST resolve access based on the record, workspace relationship, actor entitlement, and referenced tenant entitlement where applicable, and MUST NOT require the referenced tenant to match the currently remembered tenant.
|
||||
- **FR-010**: Tenant-bound pages MUST resolve legitimacy from the route tenant, workspace relationship, entitlement checks, and page semantics rather than from current header selection.
|
||||
- **FR-011**: Lifecycle-related actions MUST use labels that accurately describe their behavior, including `Archive`, `Restore`, and `Resume onboarding`, and MUST NOT use misleading labels such as `Deactivate` for archive behavior.
|
||||
- **FR-012**: The system MUST provide centralized lifecycle presentation so every valid lifecycle renders with explicit label and consistent badge semantics across in-scope surfaces.
|
||||
- **FR-013**: Authorization for workspace and tenant surfaces MUST distinguish deny-as-not-found for non-members or non-entitled actors from forbidden for members lacking a required capability.
|
||||
- **FR-014**: Onboarding workflow records and tenant lifecycle records MUST remain distinct but linked, with workflow progression owned by onboarding records and durable lifecycle semantics owned by the tenant record.
|
||||
- **FR-015**: New or changed code in scope MUST NOT introduce additional ad hoc lifecycle checks where central lifecycle, operability, or presentation rules are required.
|
||||
- **FR-016**: The product MUST classify affected pages as workspace-scoped pages, tenant-bound pages, onboarding workflow pages, or canonical workspace-level record viewers, and apply lifecycle and context rules according to that page type.
|
||||
- **FR-017**: Lifecycle transitions and lifecycle-driven action semantics MUST remain auditable and unambiguous so future audit consumers can distinguish onboarding, active, and archived behavior without inference.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant selector / context chooser | `/admin`, `/admin/choose-tenant` | None beyond selector affordance | Tenant name and lifecycle label | `Select Tenant` for `active` only | None | `View Managed Tenants` when no active tenant is selectable | None | Not applicable | No direct mutation | Current slice defines exact chooser behavior; onboarding and archived tenants are visible only through management routes, not selectable here. |
|
||||
| Managed tenants index | `/admin/tenants` | `Create Tenant` when already available, plus `Start Onboarding` when the surface already exposes onboarding entry | Linked tenant row inspection via row click | Visible actions are `Resume onboarding` when resumable and one lifecycle action from `Archive` or `Restore` when valid; any additional actions move into `More` | None in this slice | `Create Tenant` and `Start Onboarding` | `Archive`, `Restore`, `Resume onboarding`, and `View related onboarding` when valid | Save and cancel semantics unchanged | Yes for lifecycle mutations | `Archive` and `Restore` are destructive-like and must require confirmation. |
|
||||
| Tenant detail page | `/admin/tenants/{tenant}` | None added by this slice | Route-record view | None beyond route-record inspection | None | Not applicable | `Archive`, `Restore`, `Resume onboarding`, and `View related onboarding` when valid | Save and cancel semantics unchanged where edit exists | Yes for lifecycle mutations | Route legitimacy comes from the route tenant, not remembered header context. |
|
||||
| Onboarding workflow pages | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | `Start Onboarding` on index when already available | Linked onboarding draft or linked tenant entry | `Resume onboarding` and `View Tenant` when valid | None | `Start Onboarding` | `View Tenant` when a linked tenant exists and is viewable | Save and cancel semantics unchanged inside onboarding flows | Yes when workflow state mutates | These pages must not masquerade as normal active-tenant context pages. |
|
||||
| Operations index and run viewer | `/admin/operations`, `/admin/operations/{run}` | None added by this slice | Linked run row inspection | `View Run` | None in this slice | None | None | Not applicable | Existing operation audit rules remain | Exemption: this slice defines viewer legitimacy and mismatch handling, not new run mutations. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Workspace**: The primary operating boundary that owns tenants, onboarding workflow records, and canonical operation records.
|
||||
- **Tenant**: A durable workspace-owned record with canonical lifecycle state and lifecycle-driven operability semantics.
|
||||
- **Onboarding Workflow Record**: A workspace-scoped draft or session that governs onboarding progression, resumability, and setup progress for a linked tenant.
|
||||
- **Operation Run**: A canonical workspace-owned record that may reference a tenant but remains authoritative on its own route.
|
||||
- **Remembered Tenant Context**: Operator preference state used for convenience filtering and navigation, but not as an authorization source or route-legitimacy requirement.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In validation scenarios across in-scope tenant and operation surfaces, 100% of canonical lifecycle states render an explicit lifecycle label with no fallback `Unknown` state for valid values.
|
||||
- **SC-002**: In validation scenarios for canonical workspace record viewers, 100% of authorized deep links remain accessible when the remembered tenant context differs from the record's referenced tenant.
|
||||
- **SC-003**: In validation scenarios for tenant selection, 0 `draft`, `onboarding`, or `archived` tenants appear as selectable choices in the normal active tenant selector.
|
||||
- **SC-004**: In validation scenarios for tenant management actions, 100% of lifecycle-changing actions use operator-facing labels that match the underlying lifecycle outcome.
|
||||
- **SC-005**: In validation scenarios for authorization boundaries, 100% of non-member or non-entitled access attempts resolve as deny-as-not-found, while 100% of member-without-capability attempts resolve as forbidden.
|
||||
- **SC-006**: Follow-up implementation specs derived from this foundation can classify every affected route into one of the four page categories without introducing a fifth ad hoc category for tenant-context behavior.
|
||||
|
||||
## Summary
|
||||
|
||||
Tenant lifecycle behavior is currently underspecified and interpreted differently across onboarding, tenant context selection, operation viewing, action availability, and status rendering. This feature establishes an explicit product model that separates tenant lifecycle, tenant operability, and tenant context semantics so that onboarding tenants remain real records without leaking into normal active-tenant workflows, and it applies that model immediately to the current highest-risk admin surfaces.
|
||||
|
||||
The central product decision is that a tenant in `onboarding` is a real tenant record, but it is not a normal operable tenant context. That distinction creates a clean boundary between onboarding workflow, tenant management, workspace-level monitoring, and everyday tenant-context operations.
|
||||
|
||||
## Goals
|
||||
|
||||
- Define a single conceptual model for tenant lifecycle that all product layers can share.
|
||||
- Define a separate operability model so raw lifecycle is not used as a shortcut for every decision.
|
||||
- Define explicit tenant-context and selector visibility semantics for workspace pages, tenant-bound pages, onboarding pages, and canonical record viewers.
|
||||
- Ensure canonical workspace-level records such as operation runs are not invalidated by remembered tenant context.
|
||||
- Provide a stable foundation for future governance features such as alerts, audit log, baselines, findings, and customer-facing read-only views.
|
||||
- Deliver the first implementation slice on canonical operation viewers, tenant selection flows, lifecycle presentation, and tenant-safe global search so the model is proven on live admin surfaces.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- This feature does not implement a full state machine for every onboarding step.
|
||||
- This feature does not redesign the onboarding wizard end to end.
|
||||
- This feature does not introduce new tenant lifecycle states beyond `draft`, `onboarding`, `active`, and `archived`.
|
||||
- This feature does not remove the tenant record from onboarding flows.
|
||||
- This feature does not refactor every affected page immediately; it defines the target model and implements it on the current high-risk surfaces called out in the user stories.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Workspaces remain the primary ownership and authorization boundary.
|
||||
- Tenants remain workspace-owned records rather than transient onboarding-only placeholders.
|
||||
- Existing canonical operation records already have legitimate workspace identity and may optionally reference a tenant.
|
||||
- This feature's implementation work will centralize lifecycle semantics, operability rules, lifecycle presentation, lifecycle audit coverage, and tenant-safe global-search behavior rather than duplicating them per screen.
|
||||
|
||||
## Risks
|
||||
|
||||
- Centralizing lifecycle semantics without classifying page types could still leave context behavior inconsistent.
|
||||
- Future work may regress if lifecycle is again used as a shortcut for every visibility or authorization decision.
|
||||
- Removing onboarding tenants from normal selectors without strong management discoverability would make records feel lost.
|
||||
- Partial rollout across surfaces may temporarily increase confusion if follow-up implementation order is not controlled.
|
||||
|
||||
## Follow-Up Work
|
||||
|
||||
- Extend the same lifecycle and operability semantics to the remaining tenant-bound and workspace-scoped admin surfaces not covered by this initial rollout.
|
||||
- Apply the same model to future governance surfaces such as alerts, findings, and customer-facing read-only views.
|
||||
- Expand lifecycle-driven observability and audit analysis once additional tenant lifecycle mutations are introduced.
|
||||
|
||||
## Final Direction
|
||||
|
||||
The strategic direction is to make tenant lifecycle a domain concern, tenant operability a separate policy concern, and tenant context visibility a separate information architecture and UX concern. Canonical workspace-level records must remain authoritative on their own routes, and onboarding tenants must remain real records without being treated as normal active tenant context.
|
||||
@ -0,0 +1,241 @@
|
||||
# Tasks: Tenant Lifecycle, Operability, and Context Semantics Foundation
|
||||
|
||||
**Input**: Design documents from `/specs/143-tenant-lifecycle-operability-context-semantics/`
|
||||
**Prerequisites**: plan.md, spec.md, checklists/requirements.md, research.md, data-model.md, quickstart.md, contracts/admin-tenant-context-foundation.openapi.yaml
|
||||
|
||||
**Tests**: Runtime behavior changes in this repo require Pest coverage. Each user story below includes the focused tests needed to keep the change independently verifiable.
|
||||
**Operations**: This foundation changes canonical `OperationRun` viewer semantics but does not add a new run type or new queued work. Tasks must preserve existing `OperationRunService` ownership and canonical Monitoring navigation.
|
||||
**RBAC**: This feature changes admin-plane authorization semantics. Tasks must preserve 404 vs 403 rules, policy enforcement, canonical capability registry usage, and tenant-safe global-search behavior.
|
||||
**Badges**: Tenant lifecycle badge behavior must stay centralized through `BadgeCatalog` and `TenantStatusBadge`.
|
||||
**Audit**: Any lifecycle mutation or newly clarified lifecycle action semantics must emit explicit workspace audit entries through the existing audit layer.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare shared fixtures and support types used by every story.
|
||||
|
||||
- [X] T001 Create tenant lifecycle factory states in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/factories/TenantFactory.php
|
||||
- [X] T002 [P] Create operation-run mismatch fixtures in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/factories/OperationRunFactory.php
|
||||
- [X] T003 [P] Create onboarding draft lifecycle fixtures in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/factories/TenantOnboardingSessionFactory.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Central abstractions that all stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start before this phase is complete.
|
||||
|
||||
- [X] T004 Create canonical tenant lifecycle enum in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantLifecycle.php
|
||||
- [X] T005 [P] Create page category enum in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantPageCategory.php
|
||||
- [X] T006 [P] Create tenant operability decision DTO in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantOperabilityDecision.php
|
||||
- [X] T007 Implement central operability rules in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantOperabilityService.php
|
||||
- [X] T008 Integrate canonical lifecycle helpers into /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/Tenant.php
|
||||
- [X] T009 [P] Wire remembered tenant eligibility helpers into /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Workspaces/WorkspaceContext.php
|
||||
- [X] T010 [P] Wire active entitled tenant resolution to operability rules in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OperateHub/OperateHubShell.php
|
||||
- [X] T011 [P] Extend lifecycle audit action identifiers in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Audit/AuditActionId.php
|
||||
- [X] T012 [P] Add lifecycle audit logging helper coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Audit/WorkspaceAuditLogger.php
|
||||
- [X] T013 [P] Add unit coverage for lifecycle and operability decisions in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityServiceTest.php
|
||||
- [X] T014 [P] Add unit coverage for lifecycle enum normalization in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantLifecycleTest.php
|
||||
- [X] T015 [P] Add unit coverage for page-category resolution in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantPageCategoryTest.php
|
||||
|
||||
**Checkpoint**: Central lifecycle, operability, and audit helpers are available for all user stories.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Trust Canonical Record Views (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Canonical workspace-owned record viewers remain valid when remembered tenant context differs from the record’s tenant.
|
||||
|
||||
**Independent Test**: Open a valid `/admin/operations/{run}` route while another tenant is selected and verify the page still renders for entitled users, while non-members still receive 404 and members missing capability still receive 403.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T016 [P] [US1] Update canonical operation viewer behavior tests in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
- [X] T017 [P] [US1] Update remembered-context mismatch regression coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php
|
||||
- [X] T018 [P] [US1] Update canonical URL and tenant entitlement tests in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
- [X] T019 [P] [US1] Update positive and negative authorization coverage for run access in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/RunAuthorizationTenantIsolationTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T020 [US1] Refactor canonical viewer mount and mismatch handling in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
- [X] T021 [US1] Refactor canonical run URL generation and back-navigation behavior in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OperationRunLinks.php
|
||||
- [X] T022 [US1] Apply workspace-scoped filter semantics to operation lists and defaults in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/OperationRunResource.php
|
||||
- [X] T023 [US1] Preserve policy-based 404 and 403 semantics for canonical runs in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Policies/OperationRunPolicy.php
|
||||
- [X] T024 [US1] Align operations route semantics with canonical viewer rules in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/routes/web.php
|
||||
- [X] T025 [US1] Preserve canonical viewer page-category behavior in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Concerns/ResolvesPanelTenantContext.php
|
||||
|
||||
**Checkpoint**: Canonical operation run viewing works independently of remembered tenant context and remains authorization-safe.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Select Only Operable Tenants (Priority: P2)
|
||||
|
||||
**Goal**: Standard tenant selection surfaces expose only valid normal operating tenants, while onboarding and archived tenants remain visible in the right management surfaces.
|
||||
|
||||
**Independent Test**: Seed tenants in `draft`, `onboarding`, `active`, and `archived` states, then verify that `/admin/choose-tenant`, tenant-selection posts, and tenant global search expose only eligible tenants while onboarding and administrative pages still show the non-active tenants appropriately.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T026 [P] [US2] Update tenant chooser lifecycle eligibility tests in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Auth/TenantChooserSelectionTest.php
|
||||
- [X] T027 [P] [US2] Update remembered tenant switcher scope tests in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
|
||||
- [X] T028 [P] [US2] Add managed-tenants landing lifecycle visibility coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php
|
||||
- [X] T029 [P] [US2] Update archived tenant route access expectations in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php
|
||||
- [X] T030 [P] [US2] Add tenant global-search lifecycle scope coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T031 [US2] Apply central operability filtering to chooser rendering in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/ChooseTenant.php
|
||||
- [X] T032 [US2] Apply central operability filtering to tenant selection writes in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Http/Controllers/SelectTenantController.php
|
||||
- [X] T033 [US2] Clear or ignore ineligible remembered tenant context in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Http/Controllers/ClearTenantContextController.php
|
||||
- [X] T034 [US2] Enforce workspace-level remembered tenant validity in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Workspaces/WorkspaceContext.php
|
||||
- [X] T035 [US2] Align managed-tenant landing visibility with operability rules in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
|
||||
- [X] T036 [US2] Align workspace selection and graceful fallback behavior in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Http/Middleware/EnsureWorkspaceSelected.php
|
||||
- [X] T037 [US2] Scope tenant global search to eligible tenant records in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php
|
||||
|
||||
**Checkpoint**: Tenant selectors, remembered context, and global search enforce active-only operating selection without making onboarding or archived tenants disappear from management flows.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Understand Lifecycle and Actions Clearly (Priority: P3)
|
||||
|
||||
**Goal**: Tenant lifecycle labels, badges, actions, and audit behavior are centrally defined and semantically accurate across tenant management and onboarding surfaces.
|
||||
|
||||
**Independent Test**: Inspect tenant management and onboarding surfaces and verify that each lifecycle renders explicitly, archive-like actions use `Archive`, onboarding tenants can expose `Resume onboarding` when valid, no valid lifecycle renders as `Unknown`, and lifecycle mutations emit the expected audit records.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T038 [P] [US3] Update tenant lifecycle badge mapping coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Badges/TenantStatusBadgeTest.php
|
||||
- [X] T039 [P] [US3] Update tenant resource authorization and action visibility coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantResourceAuthorizationTest.php
|
||||
- [X] T040 [P] [US3] Update archive action enforcement coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
|
||||
- [X] T041 [P] [US3] Update onboarding lifecycle visibility and resume semantics coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php
|
||||
- [X] T042 [P] [US3] Update onboarding authorization semantics for linked tenants in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php
|
||||
- [X] T043 [P] [US3] Add lifecycle audit log coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Audit/TenantLifecycleAuditLogTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T044 [US3] Centralize tenant lifecycle badge presentation in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/TenantStatusBadge.php
|
||||
- [X] T045 [US3] Wire tenant lifecycle badge normalization through /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeCatalog.php
|
||||
- [X] T046 [US3] Align tenant table actions, filters, labels, and audit hooks with lifecycle-safe semantics in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php
|
||||
- [X] T047 [US3] Align onboarding wizard resume and view affordances with tenant lifecycle boundaries in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [X] T048 [US3] Preserve onboarding workflow versus tenant lifecycle boundaries and audit emission in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Onboarding/OnboardingLifecycleService.php
|
||||
- [X] T049 [US3] Preserve draft resolution and entitlement semantics for linked tenants in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Onboarding/OnboardingDraftResolver.php
|
||||
- [X] T050 [US3] Preserve workflow-model lifecycle boundaries in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/TenantOnboardingSession.php
|
||||
|
||||
**Checkpoint**: Lifecycle presentation, lifecycle-driven actions, and lifecycle audit behavior are centrally defined, semantically accurate, and independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Close gaps that affect multiple stories and run final validation.
|
||||
|
||||
- [X] T051 [P] Add a guard against new ad hoc lifecycle checks in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocTenantLifecycleChecksSpec143Test.php
|
||||
- [X] T052 Run the focused Spec 143 validation suite from /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/143-tenant-lifecycle-operability-context-semantics/quickstart.md using /Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail artisan test --compact
|
||||
- [X] T053 Run formatting for all changed PHP files with /Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail bin pint --dirty --format agent
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion.
|
||||
- **Polish (Phase 6)**: Depends on the completion of the user stories included in the delivery scope.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependency on other user stories. This is the MVP because it resolves the highest-trust canonical viewer failure.
|
||||
- **US2**: Depends only on foundational operability and remembered-context helpers, not on US1.
|
||||
- **US3**: Depends only on foundational lifecycle, operability, and audit abstractions, not on US1 or US2, though it benefits from their shared helpers.
|
||||
|
||||
### Recommended Order
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1 as the MVP trust fix.
|
||||
3. Deliver US2 to align selector and global-search behavior with operability rules.
|
||||
4. Deliver US3 to unify lifecycle presentation, action semantics, and audit coverage.
|
||||
5. Run polish validation and formatting before merge.
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel after `T001`.
|
||||
- `T005`, `T006`, `T009`, `T010`, `T011`, `T012`, `T013`, `T014`, and `T015` can run in parallel once `T004` is started.
|
||||
- All test tasks inside each user story marked `[P]` can run in parallel.
|
||||
- In US1, `T020`, `T021`, `T022`, `T023`, and `T025` can be split across contributors after the tests are in place.
|
||||
- In US2, `T031`, `T032`, `T033`, `T034`, `T035`, `T036`, and `T037` can be split by controller, middleware, page, and search surfaces after the foundational service exists.
|
||||
- In US3, `T044`, `T045`, `T046`, `T047`, `T048`, `T049`, and `T050` can be split between badge, Filament surface, onboarding-service, and audit work.
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallelize the focused US1 regression tests:
|
||||
Task: T016 Update canonical operation viewer behavior tests in tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
Task: T017 Update remembered-context mismatch regression coverage in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php
|
||||
Task: T018 Update canonical URL and tenant entitlement tests in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
Task: T019 Update positive and negative authorization coverage for run access in tests/Feature/RunAuthorizationTenantIsolationTest.php
|
||||
|
||||
# Then parallelize the implementation seams:
|
||||
Task: T020 Refactor canonical viewer mount and mismatch handling in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
Task: T021 Refactor canonical run URL generation and back-navigation behavior in app/Support/OperationRunLinks.php
|
||||
Task: T022 Apply workspace-scoped filter semantics to operation lists and defaults in app/Filament/Resources/OperationRunResource.php
|
||||
Task: T023 Preserve policy-based 404 and 403 semantics for canonical runs in app/Policies/OperationRunPolicy.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallelize selector and search-scope tests:
|
||||
Task: T026 Update tenant chooser lifecycle eligibility tests in tests/Feature/Auth/TenantChooserSelectionTest.php
|
||||
Task: T027 Update remembered tenant switcher scope tests in tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
|
||||
Task: T028 Add managed-tenants landing lifecycle visibility coverage in tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php
|
||||
Task: T029 Update archived tenant route access expectations in tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php
|
||||
Task: T030 Add tenant global-search lifecycle scope coverage in tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php
|
||||
|
||||
# Then split controller, page, and search work:
|
||||
Task: T031 Apply central operability filtering to chooser rendering in app/Filament/Pages/ChooseTenant.php
|
||||
Task: T032 Apply central operability filtering to tenant selection writes in app/Http/Controllers/SelectTenantController.php
|
||||
Task: T035 Align managed-tenant landing visibility with operability rules in app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
|
||||
Task: T037 Scope tenant global search to eligible tenant records in app/Filament/Resources/TenantResource.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallelize lifecycle presentation and audit tests:
|
||||
Task: T038 Update tenant lifecycle badge mapping coverage in tests/Feature/Badges/TenantStatusBadgeTest.php
|
||||
Task: T039 Update tenant resource authorization and action visibility coverage in tests/Feature/Rbac/TenantResourceAuthorizationTest.php
|
||||
Task: T040 Update archive action enforcement coverage in tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
|
||||
Task: T043 Add lifecycle audit log coverage in tests/Feature/Audit/TenantLifecycleAuditLogTest.php
|
||||
|
||||
# Then split badge, resource, onboarding, and audit implementation:
|
||||
Task: T044 Centralize tenant lifecycle badge presentation in app/Support/Badges/Domains/TenantStatusBadge.php
|
||||
Task: T046 Align tenant table actions, filters, labels, and audit hooks with lifecycle-safe semantics in app/Filament/Resources/TenantResource.php
|
||||
Task: T048 Preserve onboarding workflow versus tenant lifecycle boundaries and audit emission in app/Services/Onboarding/OnboardingLifecycleService.php
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
- Deliver Phase 1, Phase 2, and Phase 3 first.
|
||||
- This yields the highest-value trust fix: canonical workspace record viewers no longer fail because of remembered tenant mismatch.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- After the MVP, deliver Phase 4 to make tenant selection and tenant global search match the new operability model.
|
||||
- Then deliver Phase 5 to unify lifecycle presentation, action semantics, and audit behavior across tenant management and onboarding.
|
||||
- Finish with Phase 6 guardrails, focused validation, and formatting.
|
||||
|
||||
### Task Count Summary
|
||||
|
||||
- **Total tasks**: 53
|
||||
- **Setup tasks**: 3
|
||||
- **Foundational tasks**: 12
|
||||
- **US1 tasks**: 10
|
||||
- **US2 tasks**: 12
|
||||
- **US3 tasks**: 13
|
||||
- **Polish tasks**: 3
|
||||
57
tests/Feature/Audit/TenantLifecycleAuditLogTest.php
Normal file
57
tests/Feature/Audit/TenantLifecycleAuditLogTest.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('records archive and restore audit entries for tenant lifecycle mutations from the tenant view', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create(['name' => 'Lifecycle Audit Tenant']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionExists('archive', function (Action $action): bool {
|
||||
return $action->getLabel() === 'Archive' && $action->isConfirmationRequired();
|
||||
})
|
||||
->mountAction('archive')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
$tenant->refresh();
|
||||
|
||||
expect($tenant->trashed())->toBeTrue();
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', AuditActionId::TenantArchived->value)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionExists('restore', function (Action $action): bool {
|
||||
return $action->getLabel() === 'Restore' && $action->isConfirmationRequired();
|
||||
})
|
||||
->mountAction('restore')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
$tenant->refresh();
|
||||
|
||||
expect($tenant->trashed())->toBeFalse();
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', AuditActionId::TenantRestored->value)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
@ -5,50 +5,79 @@
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('redirects to the chosen tenant dashboard and persists the last-used tenant', function () {
|
||||
$user = User::factory()->create();
|
||||
it('shows only active tenants in the standard chooser and persists the last-used tenant', function (): void {
|
||||
$activeTenant = Tenant::factory()->active()->create(['name' => 'Active Tenant']);
|
||||
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
$otherTenant = Tenant::factory()->create(['status' => 'active']);
|
||||
$otherActiveTenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Other Active Tenant',
|
||||
]);
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Onboarding Tenant',
|
||||
]);
|
||||
$draftTenant = Tenant::factory()->draft()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Draft Tenant',
|
||||
]);
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Archived Tenant',
|
||||
]);
|
||||
|
||||
foreach ([$tenant, $otherTenant] as $memberTenant) {
|
||||
TenantMembership::query()->create([
|
||||
'tenant_id' => $memberTenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
'source_ref' => null,
|
||||
'created_by_user_id' => null,
|
||||
]);
|
||||
}
|
||||
createUserWithTenant(tenant: $otherActiveTenant, user: $user, role: 'owner');
|
||||
createUserWithTenant(tenant: $onboardingTenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
createUserWithTenant(tenant: $draftTenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
createUserWithTenant(tenant: $archivedTenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $activeTenant->workspace_id);
|
||||
|
||||
$this->get('/admin/choose-tenant')
|
||||
->assertSuccessful()
|
||||
->assertSee('Active Tenant')
|
||||
->assertSee('Other Active Tenant')
|
||||
->assertDontSee('Onboarding Tenant')
|
||||
->assertDontSee('Draft Tenant')
|
||||
->assertDontSee('Archived Tenant');
|
||||
|
||||
Livewire::test(ChooseTenant::class)
|
||||
->call('selectTenant', (int) $tenant->getKey())
|
||||
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
->call('selectTenant', (int) $activeTenant->getKey())
|
||||
->assertRedirect(TenantDashboard::getUrl(tenant: $activeTenant));
|
||||
|
||||
$user->refresh();
|
||||
|
||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||
expect($user->last_tenant_id)->toBe($tenant->getKey());
|
||||
expect($user->last_tenant_id)->toBe($activeTenant->getKey());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_tenant_preferences')) {
|
||||
$preference = $user->tenantPreferences()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('tenant_id', $activeTenant->getKey())
|
||||
->first();
|
||||
|
||||
expect($preference)->not->toBeNull();
|
||||
expect($preference?->last_used_at)->not->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 404 when a non-operable tenant is selected through the chooser endpoint', function (): void {
|
||||
$tenant = Tenant::factory()->onboarding()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->post(route('admin.select-tenant'), ['tenant_id' => (int) $tenant->getKey()])
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -1,18 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps pending tenant status to a Pending warning badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'pending');
|
||||
it('maps every canonical tenant lifecycle to an explicit badge', function (string $input, string $label, string $color): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::TenantStatus, $input);
|
||||
|
||||
expect($spec->label)->toBe('Pending');
|
||||
expect($spec->color)->toBe('warning');
|
||||
expect($spec->icon)->toBe('heroicon-m-clock');
|
||||
});
|
||||
|
||||
it('normalizes tenant status input before mapping', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'PENDING');
|
||||
|
||||
expect($spec->label)->toBe('Pending');
|
||||
expect($spec->label)->toBe($label)
|
||||
->and($spec->color)->toBe($color);
|
||||
})->with([
|
||||
'draft' => ['draft', 'Draft', 'gray'],
|
||||
'onboarding' => ['onboarding', 'Onboarding', 'warning'],
|
||||
'active' => ['active', 'Active', 'success'],
|
||||
'archived' => ['archived', 'Archived', 'gray'],
|
||||
]);
|
||||
|
||||
it('normalizes legacy tenant lifecycle aliases before mapping', function (): void {
|
||||
$pending = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'PENDING');
|
||||
$inactive = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'inactive');
|
||||
|
||||
expect($pending->label)->toBe('Onboarding')
|
||||
->and($inactive->label)->toBe('Archived');
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
@ -10,9 +11,36 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not show the register-tenant CTA for readonly workspace members when there are no tenants', function (): void {
|
||||
it('shows the managed-tenants CTA when a workspace has no selectable active tenants', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$onboardingTenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get('/admin/choose-tenant')
|
||||
->assertSuccessful()
|
||||
->assertSee('No active tenants available')
|
||||
->assertSee('View managed tenants')
|
||||
->assertDontSee('Register tenant')
|
||||
->assertDontSee('Add tenant');
|
||||
});
|
||||
|
||||
it('keeps the empty state safe for readonly workspace members', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
@ -25,26 +53,7 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get('/admin/choose-tenant')
|
||||
->assertSuccessful()
|
||||
->assertSee('No tenants available')
|
||||
->assertDontSee('Register tenant');
|
||||
});
|
||||
|
||||
it('does not show the register-tenant CTA for owner workspace members when there are no tenants', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get('/admin/choose-tenant')
|
||||
->assertSuccessful()
|
||||
->assertSee('No tenants available')
|
||||
->assertSee('No active tenants available')
|
||||
->assertSee('Switch workspace')
|
||||
->assertDontSee('Register tenant');
|
||||
});
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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('shows onboarding and archived tenants on the managed-tenants landing with lifecycle labels', function (): void {
|
||||
$workspace = Workspace::factory()->create(['slug' => 'lifecycle-ws']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$active = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Active Tenant',
|
||||
]);
|
||||
$onboarding = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Onboarding Tenant',
|
||||
]);
|
||||
$archived = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Archived Tenant',
|
||||
]);
|
||||
$outsider = Tenant::factory()->active()->create(['name' => 'Other Workspace Tenant']);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$active->getKey() => ['role' => 'owner'],
|
||||
$onboarding->getKey() => ['role' => 'owner'],
|
||||
$archived->getKey() => ['role' => 'owner'],
|
||||
$outsider->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Active Tenant')
|
||||
->assertSee('Onboarding Tenant')
|
||||
->assertSee('Archived Tenant')
|
||||
->assertSee('Active')
|
||||
->assertSee('Onboarding')
|
||||
->assertSee('Archived')
|
||||
->assertDontSee('Other Workspace Tenant');
|
||||
});
|
||||
@ -123,7 +123,7 @@ function operationRunFilterIndicatorLabels($component): array
|
||||
->assertCanSeeTableRecords([$recent, $old]);
|
||||
});
|
||||
|
||||
it('scopes operation type filter options to the remembered tenant context', function (): void {
|
||||
it('keeps operation type filter options workspace-scoped even when a remembered tenant is active', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -160,6 +160,7 @@ function operationRunFilterIndicatorLabels($component): array
|
||||
|
||||
expect($filter)->not->toBeNull();
|
||||
expect($filter?->getOptions())->toBe([
|
||||
'inventory_sync' => 'Inventory sync',
|
||||
'policy.sync' => 'Policy sync',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function tenantSearchTitles($results): array
|
||||
{
|
||||
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 {
|
||||
$active = Tenant::factory()->active()->create(['name' => 'Lifecycle Active']);
|
||||
[$user, $active] = createUserWithTenant(tenant: $active, role: 'owner');
|
||||
|
||||
$onboarding = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $active->workspace_id,
|
||||
'name' => 'Lifecycle Onboarding',
|
||||
]);
|
||||
$draft = Tenant::factory()->draft()->create([
|
||||
'workspace_id' => (int) $active->workspace_id,
|
||||
'name' => 'Lifecycle Draft',
|
||||
]);
|
||||
$archived = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $active->workspace_id,
|
||||
'name' => 'Lifecycle Archived',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $onboarding, user: $user, role: 'owner');
|
||||
createUserWithTenant(tenant: $draft, user: $user, role: 'owner');
|
||||
createUserWithTenant(tenant: $archived, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $active->workspace_id);
|
||||
|
||||
$results = TenantResource::getGlobalSearchResults('Lifecycle');
|
||||
|
||||
expect(tenantSearchTitles($results))->toBe([
|
||||
'Lifecycle Active',
|
||||
]);
|
||||
|
||||
expect($results->first()?->url)
|
||||
->toBe(TenantResource::getUrl('view', ['record' => $active], panel: 'admin'));
|
||||
});
|
||||
@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
@ -65,4 +66,70 @@
|
||||
|
||||
expect(Tenant::withTrashed()->find($tenant->getKey())?->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
it('shows resume onboarding when the tenant has a resumable linked onboarding draft', function () {
|
||||
$tenant = Tenant::factory()->onboarding()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
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,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionExists('related_onboarding', function (Action $action): bool {
|
||||
return $action->getLabel() === 'Resume onboarding';
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a cancelled-onboarding label and repairs stale onboarding tenant status when the linked draft was cancelled', function () {
|
||||
$tenant = Tenant::factory()->onboarding()->create([
|
||||
'name' => 'Cancelled Flow Tenant',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $tenant->workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'status' => 'cancelled',
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'tenant_name' => (string) $tenant->name,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('This onboarding draft is Cancelled.');
|
||||
|
||||
$tenant->refresh();
|
||||
|
||||
expect($tenant->status)->toBe(Tenant::STATUS_DRAFT);
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', \App\Support\Audit\AuditActionId::TenantReturnedToDraft->value)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionExists('related_onboarding', function (Action $action): bool {
|
||||
return $action->getLabel() === 'View cancelled onboarding';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('avoids raw tenant lifecycle string checks in the spec 143 coordination seams', function (): void {
|
||||
$files = [
|
||||
base_path('app/Filament/Pages/ChooseTenant.php'),
|
||||
base_path('app/Http/Controllers/SelectTenantController.php'),
|
||||
base_path('app/Support/Workspaces/WorkspaceContext.php'),
|
||||
base_path('app/Support/OperateHub/OperateHubShell.php'),
|
||||
base_path('app/Filament/Pages/Workspaces/ManagedTenantsLanding.php'),
|
||||
base_path('app/Filament/Resources/TenantResource.php'),
|
||||
base_path('app/Filament/Resources/TenantResource/Pages/ViewTenant.php'),
|
||||
];
|
||||
|
||||
$forbiddenPatterns = [
|
||||
'/where\(\s*[\'"]status[\'"]\s*,\s*[\'"](draft|onboarding|active|archived)[\'"]\s*\)/',
|
||||
'/->status\s*===\s*[\'"](draft|onboarding|active|archived)[\'"]/',
|
||||
'/status\s*=>\s*[\'"](draft|onboarding|active|archived)[\'"]/',
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$contents = file_get_contents($file);
|
||||
|
||||
expect($contents)->not->toBeFalse();
|
||||
|
||||
foreach ($forbiddenPatterns as $pattern) {
|
||||
expect((int) preg_match($pattern, (string) $contents))
|
||||
->toBe(0, "Unexpected ad hoc lifecycle check in {$file}: {$pattern}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -87,6 +87,41 @@
|
||||
->assertDontSee('Clear tenant scope');
|
||||
});
|
||||
|
||||
it('renders the routed tenant as read-only context on tenant resource view pages', function (): void {
|
||||
$currentTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'YPTW2',
|
||||
'environment' => 'dev',
|
||||
]);
|
||||
[$user, $currentTenant] = createUserWithTenant($currentTenant, role: 'owner');
|
||||
|
||||
$routedTenant = Tenant::factory()->create([
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'workspace_id' => (int) $currentTenant->workspace_id,
|
||||
'name' => 'Test',
|
||||
'environment' => 'dev',
|
||||
]);
|
||||
|
||||
createUserWithTenant($routedTenant, $user, role: 'owner');
|
||||
|
||||
Filament::setTenant($currentTenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $currentTenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $currentTenant->workspace_id => (int) $currentTenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get(route('filament.admin.resources.tenants.view', ['record' => $routedTenant]))
|
||||
->assertOk()
|
||||
->assertSee($routedTenant->getFilamentName())
|
||||
->assertSee('Switch tenant')
|
||||
->assertDontSee('Search tenants…')
|
||||
->assertDontSee('admin/select-tenant')
|
||||
->assertDontSee('Clear tenant scope');
|
||||
});
|
||||
|
||||
it('filters the header tenant picker to tenants the user can access', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly');
|
||||
|
||||
@ -82,7 +82,7 @@
|
||||
->assertSee('Operation run');
|
||||
});
|
||||
|
||||
it('returns not found for operation detail when the active tenant context does not match the run tenant', function (): void {
|
||||
it('keeps operation detail accessible when the active tenant context does not match the run tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -103,7 +103,8 @@
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
|
||||
->assertNotFound();
|
||||
->assertOk()
|
||||
->assertSee('Operation run');
|
||||
});
|
||||
|
||||
it('defaults the tenant filter from tenant context and can be cleared', function (): void {
|
||||
@ -144,8 +145,8 @@
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSee('TenantA')
|
||||
->assertDontSee('TenantB')
|
||||
->assertCanSeeTableRecords([$runA])
|
||||
->assertCanNotSeeTableRecords([$runB])
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey());
|
||||
|
||||
$component
|
||||
|
||||
@ -161,3 +161,81 @@
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('returns 404 for workspace members without linked archived tenant entitlement', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$creator = User::factory()->create();
|
||||
$workspaceOwner = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $archivedTenant,
|
||||
user: $creator,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
\App\Models\WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $workspaceOwner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $archivedTenant,
|
||||
'started_by' => $creator,
|
||||
'updated_by' => $creator,
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $archivedTenant->tenant_id,
|
||||
'tenant_name' => (string) $archivedTenant->name,
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($workspaceOwner)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('shows a non-editable summary for entitled operators when the linked tenant is already archived', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Archived Linked Tenant',
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $archivedTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $archivedTenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $archivedTenant->tenant_id,
|
||||
'tenant_name' => (string) $archivedTenant->name,
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('This onboarding draft is Draft.')
|
||||
->assertSee('Completed, cancelled, and lifecycle-locked drafts remain viewable, but they cannot return to editable wizard mode.')
|
||||
->assertDontSee('Cancel draft')
|
||||
->assertSee('View tenant');
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
@ -39,7 +40,7 @@
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('This onboarding draft is Completed.')
|
||||
->assertSee('Completed and cancelled drafts remain viewable, but they cannot return to editable wizard mode.')
|
||||
->assertSee('Completed, cancelled, and lifecycle-locked drafts remain viewable, but they cannot return to editable wizard mode.')
|
||||
->assertSee('Return to onboarding')
|
||||
->assertDontSee('Cancel draft');
|
||||
});
|
||||
@ -72,10 +73,35 @@
|
||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('This onboarding draft is Cancelled.')
|
||||
->assertSee('Completed, cancelled, and lifecycle-locked drafts remain viewable, but they cannot return to editable wizard mode.')
|
||||
->assertSee('Return to onboarding')
|
||||
->assertDontSee('Cancel draft');
|
||||
});
|
||||
|
||||
it('shows a linked tenant action when the onboarding draft has a viewable tenant record', function (): void {
|
||||
$tenant = Tenant::factory()->onboarding()->create([
|
||||
'name' => 'Linked Onboarding 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');
|
||||
});
|
||||
|
||||
it('cancels a resumable onboarding draft through the confirmed header action', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -163,3 +189,40 @@
|
||||
->get(route('admin.onboarding'))
|
||||
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $activeDraft->getKey()]));
|
||||
});
|
||||
|
||||
it('returns a linked onboarding tenant to draft when its last onboarding draft is cancelled', function (): void {
|
||||
$tenant = Tenant::factory()->onboarding()->create([
|
||||
'name' => 'Cancelled Linked 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,
|
||||
'environment' => 'dev',
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class, [
|
||||
'onboardingDraft' => (int) $draft->getKey(),
|
||||
])
|
||||
->mountAction('cancel_onboarding_draft')
|
||||
->callMountedAction()
|
||||
->assertNotified('Onboarding draft cancelled');
|
||||
|
||||
$tenant->refresh();
|
||||
|
||||
expect($tenant->status)->toBe(Tenant::STATUS_DRAFT);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', AuditActionId::TenantReturnedToDraft->value)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\TenantRole;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -103,6 +104,50 @@
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenantA = Tenant::factory()->for($workspace)->create();
|
||||
$tenantB = Tenant::factory()->for($workspace)->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
foreach ([$tenantA, $tenantB] as $tenant) {
|
||||
$tenant->users()->attach((int) $user->getKey(), [
|
||||
'role' => TenantRole::Owner->value,
|
||||
'source' => 'manual',
|
||||
'source_ref' => null,
|
||||
'created_by_user_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenantB, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $workspace->getKey() => (int) $tenantB->getKey(),
|
||||
],
|
||||
])
|
||||
->get("/admin/operations/{$run->getKey()}")
|
||||
->assertSuccessful()
|
||||
->assertSee('Operation run')
|
||||
->assertSee('Back to Operations');
|
||||
});
|
||||
|
||||
it('renders stored target scope and failure details for a completed run', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -316,6 +316,44 @@
|
||||
expect($resolved?->is($routedTenant))->toBeTrue();
|
||||
})->group('ops-ux');
|
||||
|
||||
it('prefers the routed tenant resource record over active tenant state on admin tenant view routes', function (): void {
|
||||
$rememberedTenant = Tenant::factory()->create([
|
||||
'name' => 'YPTW2',
|
||||
'environment' => 'dev',
|
||||
'status' => 'active',
|
||||
]);
|
||||
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
||||
|
||||
$routedTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||
'name' => 'Test',
|
||||
'environment' => 'dev',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($rememberedTenant, true);
|
||||
|
||||
$workspaceId = (int) $rememberedTenant->workspace_id;
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
||||
]);
|
||||
|
||||
$request = Request::create("/admin/tenants/{$routedTenant->external_id}");
|
||||
$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($routedTenant))->toBeTrue();
|
||||
})->group('ops-ux');
|
||||
|
||||
it('shows tenant filter label when tenant context is active', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -22,7 +22,9 @@
|
||||
->assertActionVisible('archive')
|
||||
->assertActionDisabled('archive')
|
||||
->assertActionExists('archive', function (Action $action): bool {
|
||||
return $action->getTooltip() === 'You do not have permission to archive tenants.';
|
||||
return $action->getLabel() === 'Archive'
|
||||
&& $action->isConfirmationRequired()
|
||||
&& $action->getTooltip() === 'You do not have permission to archive tenants.';
|
||||
})
|
||||
->mountAction('archive')
|
||||
->callMountedAction()
|
||||
@ -44,6 +46,7 @@
|
||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('archive')
|
||||
->assertActionEnabled('archive')
|
||||
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
|
||||
->mountAction('archive')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
@ -51,4 +54,16 @@
|
||||
$tenant->refresh();
|
||||
expect($tenant->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('hides the archive action once the tenant is already archived', function () {
|
||||
$tenant = Tenant::factory()->archived()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionHidden('archive');
|
||||
});
|
||||
});
|
||||
|
||||
@ -55,4 +55,34 @@
|
||||
|
||||
expect(TenantResource::canEdit($otherTenant))->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps onboarding and archived tenants manageable when the actor is entitled', function () {
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create();
|
||||
[$user, $onboardingTenant] = createUserWithTenant(
|
||||
tenant: $onboardingTenant,
|
||||
role: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $onboardingTenant->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $archivedTenant,
|
||||
user: $user,
|
||||
role: 'manager',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$archivedTenant = Tenant::withTrashed()->findOrFail((int) $archivedTenant->getKey());
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
expect(TenantResource::canEdit($onboardingTenant))->toBeTrue()
|
||||
->and(TenantResource::canEdit($archivedTenant))->toBeTrue()
|
||||
->and(TenantResource::canDelete($onboardingTenant))->toBeFalse()
|
||||
->and(TenantResource::canDelete($archivedTenant))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -9,6 +10,7 @@
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
@ -25,7 +27,7 @@
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
$runA = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
@ -34,7 +36,7 @@
|
||||
'initiator_name' => 'Tenant A Scope',
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
$runB = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
@ -45,12 +47,14 @@
|
||||
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Tenant A Scope')
|
||||
->assertDontSee('Tenant B Scope');
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertCanSeeTableRecords([$runA])
|
||||
->assertCanNotSeeTableRecords([$runB])
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey());
|
||||
});
|
||||
|
||||
test('operation run view is not accessible cross-workspace', function (): void {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -42,3 +43,36 @@
|
||||
|
||||
expect(Filament::getTenant())->toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate remembered tenant context when opening a canonical operation run for another tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$lastTenantMap = [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
];
|
||||
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap,
|
||||
])
|
||||
->get("/admin/operations/{$run->getKey()}")
|
||||
->assertOk();
|
||||
|
||||
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY))->toBe($lastTenantMap);
|
||||
});
|
||||
|
||||
@ -81,3 +81,36 @@
|
||||
->assertSee('All tenants')
|
||||
->assertDontSee('Tenant scope: '.$tenant->name);
|
||||
});
|
||||
|
||||
it('clears remembered tenant scope even when the stored tenant is no longer operable', function (): void {
|
||||
$activeTenant = Tenant::factory()->create();
|
||||
[$user, $activeTenant] = createUserWithTenant($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,
|
||||
);
|
||||
|
||||
$workspaceId = (int) $activeTenant->workspace_id;
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $workspaceId => (int) $onboardingTenant->getKey(),
|
||||
],
|
||||
])
|
||||
->from('/admin/operations')
|
||||
->post('/admin/clear-tenant-context')
|
||||
->assertRedirect('/admin/operations');
|
||||
|
||||
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||
->not->toHaveKey((string) $workspaceId);
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -26,3 +27,14 @@
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows members to inspect the admin tenant view for archived tenants', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenant->delete();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(TenantResource::getUrl('view', ['record' => $tenant]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Archived');
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\PanelRegistry;
|
||||
@ -7,14 +9,23 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('only returns membership tenants for normal users', function () {
|
||||
it('only returns active membership tenants for normal users', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$allowed = Tenant::factory()->create(['name' => 'Allowed']);
|
||||
$blocked = Tenant::factory()->create(['name' => 'Blocked']);
|
||||
$allowed = Tenant::factory()->active()->create(['name' => 'Allowed']);
|
||||
$onboarding = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $allowed->workspace_id,
|
||||
'name' => 'Onboarding',
|
||||
]);
|
||||
$archived = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $allowed->workspace_id,
|
||||
'name' => 'Archived',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$allowed->getKey() => ['role' => 'readonly'],
|
||||
$onboarding->getKey() => ['role' => 'readonly'],
|
||||
$archived->getKey() => ['role' => 'readonly'],
|
||||
]);
|
||||
|
||||
/** @var \Filament\Panel $panel */
|
||||
@ -24,21 +35,26 @@
|
||||
|
||||
expect($tenants)->toHaveCount(1);
|
||||
expect($tenants->first()?->getKey())->toBe($allowed->getKey());
|
||||
expect($tenants->first()?->name)->toBe('Allowed');
|
||||
|
||||
expect($tenants->contains(fn (Tenant $tenant) => $tenant->getKey() === $blocked->getKey()))->toBeFalse();
|
||||
expect($tenants->contains(fn (Tenant $tenant): bool => $tenant->name === 'Onboarding'))->toBeFalse();
|
||||
expect($tenants->contains(fn (Tenant $tenant): bool => $tenant->name === 'Archived'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns all active tenants for platform superadmins', function () {
|
||||
it('returns no tenants for users without active tenant memberships', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$a = Tenant::factory()->create(['name' => 'A']);
|
||||
$b = Tenant::factory()->create(['name' => 'B']);
|
||||
$draft = Tenant::factory()->draft()->create(['name' => 'Draft']);
|
||||
$archived = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $draft->workspace_id,
|
||||
'name' => 'Archived',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$draft->getKey() => ['role' => 'readonly'],
|
||||
$archived->getKey() => ['role' => 'readonly'],
|
||||
]);
|
||||
|
||||
/** @var \Filament\Panel $panel */
|
||||
$panel = app(PanelRegistry::class)->get('admin');
|
||||
|
||||
$tenants = $user->getTenants($panel);
|
||||
|
||||
expect($tenants)->toHaveCount(0);
|
||||
expect($user->getTenants($panel))->toHaveCount(0);
|
||||
});
|
||||
|
||||
@ -83,6 +83,37 @@
|
||||
$response->assertRedirect($expectedRoute);
|
||||
});
|
||||
|
||||
it('redirects to managed tenants index when single workspace only has non-operable tenants', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
]);
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$onboardingTenant->getKey() => ['role' => 'owner'],
|
||||
$archivedTenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
|
||||
|
||||
$expectedRoute = route('admin.workspace.managed-tenants.index', [
|
||||
'workspace' => $workspace->slug ?? $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$response->assertRedirect($expectedRoute);
|
||||
});
|
||||
|
||||
it('redirects to tenant dashboard when single workspace has one active tenant', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
@ -158,3 +158,42 @@
|
||||
|
||||
expect($drafts->modelKeys())->toBe([(int) $activeDraft->getKey()]);
|
||||
});
|
||||
|
||||
it('excludes linked drafts whose tenant lifecycle no longer allows onboarding resume', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$activeTenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $activeTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$unlinkedDraft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
]);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $activeTenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $activeTenant->tenant_id,
|
||||
'tenant_name' => (string) $activeTenant->name,
|
||||
],
|
||||
]);
|
||||
|
||||
$drafts = app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $workspace);
|
||||
|
||||
expect($drafts->modelKeys())->toBe([(int) $unlinkedDraft->getKey()]);
|
||||
});
|
||||
|
||||
@ -201,3 +201,24 @@
|
||||
->and($draft->current_checkpoint)->toBe(OnboardingCheckpoint::VerifyAccess)
|
||||
->and($draft->reason_code)->toBe('provider_connection_changed');
|
||||
});
|
||||
|
||||
it('keeps linked archived tenants loaded while suppressing onboarding resume affordances', function (): void {
|
||||
$tenant = Tenant::factory()->archived()->create();
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $tenant->workspace,
|
||||
'tenant' => $tenant,
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'tenant_name' => (string) $tenant->name,
|
||||
],
|
||||
]);
|
||||
|
||||
$draft = $draft->fresh()->load('tenant');
|
||||
$service = app(OnboardingLifecycleService::class);
|
||||
|
||||
expect($draft->tenant)->toBeInstanceOf(Tenant::class)
|
||||
->and($draft->tenant?->trashed())->toBeTrue()
|
||||
->and($draft->isWorkflowResumable())->toBeTrue()
|
||||
->and($service->canResumeDraft($draft))->toBeFalse();
|
||||
});
|
||||
|
||||
25
tests/Unit/Tenants/TenantLifecycleTest.php
Normal file
25
tests/Unit/Tenants/TenantLifecycleTest.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Tenants\TenantLifecycle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('normalizes canonical and aliased lifecycle values', function (string $input, TenantLifecycle $expected): void {
|
||||
expect(TenantLifecycle::fromValue($input))->toBe($expected);
|
||||
})->with([
|
||||
'draft' => ['draft', TenantLifecycle::Draft],
|
||||
'onboarding' => ['onboarding', TenantLifecycle::Onboarding],
|
||||
'active' => ['active', TenantLifecycle::Active],
|
||||
'archived' => ['archived', TenantLifecycle::Archived],
|
||||
'pending alias' => ['pending', TenantLifecycle::Onboarding],
|
||||
]);
|
||||
|
||||
it('derives archived lifecycle from soft-deleted tenants', function (): void {
|
||||
$tenant = Tenant::factory()->archived()->create();
|
||||
|
||||
expect(TenantLifecycle::fromTenant($tenant))->toBe(TenantLifecycle::Archived);
|
||||
});
|
||||
69
tests/Unit/Tenants/TenantOperabilityServiceTest.php
Normal file
69
tests/Unit/Tenants/TenantOperabilityServiceTest.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantLifecycle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns lifecycle-aware operability decisions for every canonical tenant state', function (
|
||||
\Closure $tenantFactory,
|
||||
TenantLifecycle $expectedLifecycle,
|
||||
bool $canSelectAsContext,
|
||||
bool $canOperate,
|
||||
bool $canArchive,
|
||||
bool $canRestore,
|
||||
bool $canResumeOnboarding,
|
||||
): void {
|
||||
$tenant = $tenantFactory();
|
||||
$decision = app(TenantOperabilityService::class)->decisionFor($tenant);
|
||||
|
||||
expect($decision->lifecycle)->toBe($expectedLifecycle)
|
||||
->and($decision->canViewTenantSurface)->toBeTrue()
|
||||
->and($decision->canSelectAsContext)->toBe($canSelectAsContext)
|
||||
->and($decision->canOperate)->toBe($canOperate)
|
||||
->and($decision->canArchive)->toBe($canArchive)
|
||||
->and($decision->canRestore)->toBe($canRestore)
|
||||
->and($decision->canResumeOnboarding)->toBe($canResumeOnboarding)
|
||||
->and($decision->canReferenceInWorkspaceMonitoring)->toBeTrue();
|
||||
})->with([
|
||||
'draft' => [
|
||||
fn (): Tenant => Tenant::factory()->draft()->create(),
|
||||
TenantLifecycle::Draft,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
],
|
||||
'onboarding' => [
|
||||
fn (): Tenant => Tenant::factory()->onboarding()->create(),
|
||||
TenantLifecycle::Onboarding,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
],
|
||||
'active' => [
|
||||
fn (): Tenant => Tenant::factory()->active()->create(),
|
||||
TenantLifecycle::Active,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
'archived' => [
|
||||
fn (): Tenant => Tenant::factory()->archived()->create(),
|
||||
TenantLifecycle::Archived,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
]);
|
||||
21
tests/Unit/Tenants/TenantPageCategoryTest.php
Normal file
21
tests/Unit/Tenants/TenantPageCategoryTest.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('classifies in-scope admin routes into canonical page categories', function (string $path, TenantPageCategory $expected): void {
|
||||
expect(TenantPageCategory::fromPath($path))->toBe($expected);
|
||||
})->with([
|
||||
'workspace overview' => ['/admin', TenantPageCategory::WorkspaceScoped],
|
||||
'tenant chooser' => ['/admin/choose-tenant', TenantPageCategory::WorkspaceScoped],
|
||||
'tenant detail' => ['/admin/tenants/tenant-123', TenantPageCategory::TenantBound],
|
||||
'tenant panel route' => ['/admin/t/tenant-123', TenantPageCategory::TenantBound],
|
||||
'onboarding index' => ['/admin/onboarding', TenantPageCategory::OnboardingWorkflow],
|
||||
'onboarding draft' => ['/admin/onboarding/42', TenantPageCategory::OnboardingWorkflow],
|
||||
'operations index' => ['/admin/operations', TenantPageCategory::WorkspaceScoped],
|
||||
'operation run detail' => ['/admin/operations/44', TenantPageCategory::CanonicalWorkspaceRecordViewer],
|
||||
]);
|
||||
Loading…
Reference in New Issue
Block a user