From 3f09fd50f6edb77778648a8220a23f949f0328ca Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 7 Feb 2026 19:45:13 +0000 Subject: [PATCH] feat(spec-080): workspace-managed tenant administration migration (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Spec 080: split Filament into workspace-managed `/admin/*` (manage) vs tenant operations `/admin/t/{tenant}/*` (operate). Highlights: - Adds tenant operations panel (`tenant`) at `/admin/t` with tenancy by `Tenant.external_id` - Keeps management resources in workspace panel (`admin`) under `/admin/tenants/*` - Moves Provider Connections to workspace-managed routes: `/admin/tenants/{tenant}/provider-connections` - Adds discoverability CTA on tenant view (Actions → Provider connections) - Adds/updates Pest regression tests for routing boundaries, 404/403 RBAC-UX semantics, and global search isolation - Includes full Spec Kit artifacts under `specs/080-workspace-managed-tenant-admin/` Validation: - `vendor/bin/sail bin pint --dirty` - `vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php` Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/97 --- .github/agents/copilot-instructions.md | 4 +- app/Filament/Pages/ChooseTenant.php | 2 +- app/Filament/Pages/ChooseWorkspace.php | 2 +- app/Filament/Pages/Monitoring/Alerts.php | 2 + app/Filament/Pages/Monitoring/AuditLog.php | 2 + app/Filament/Pages/TenantDashboard.php | 9 + .../Pages/TenantRequiredPermissions.php | 52 +++- .../ManagedTenantOnboardingWizard.php | 41 ++- .../Workspaces/ManagedTenantsLanding.php | 2 +- .../Resources/ProviderConnectionResource.php | 61 +++- .../Pages/CreateProviderConnection.php | 30 +- .../Pages/EditProviderConnection.php | 47 ++- app/Filament/Resources/TenantResource.php | 16 +- .../Pages/ManageTenantMemberships.php | 8 + .../TenantResource/Pages/ViewTenant.php | 9 + .../Controllers/SelectTenantController.php | 2 +- .../Controllers/SwitchWorkspaceController.php | 2 +- .../Middleware/EnsureWorkspaceSelected.php | 4 + app/Jobs/ProviderConnectionHealthCheckJob.php | 19 +- app/Models/Tenant.php | 5 + app/Policies/ProviderConnectionPolicy.php | 27 +- app/Providers/Filament/AdminPanelProvider.php | 26 +- .../Filament/TenantPanelProvider.php | 94 ++++++ .../Links/RequiredPermissionsLinks.php | 2 +- app/Support/OperationRunLinks.php | 24 +- bootstrap/providers.php | 1 + .../tenant-required-permissions.blade.php | 3 +- .../filament/partials/context-bar.blade.php | 2 +- routes/web.php | 29 +- .../contracts/openapi.yaml | 45 +++ .../contracts/routes.md | 52 ++++ .../data-model.md | 71 +++++ .../plan.md | 188 ++++++++++++ .../quickstart.md | 36 +++ .../research.md | 45 +++ .../spec.md | 238 +++++++++++++++ .../tasks.md | 200 +++++++++++++ ...rkspaceManagedTenantAdminMigrationTest.php | 282 ++++++++++++++++++ .../TenantRouteDenyAsNotFoundTest.php | 19 ++ tests/Unit/RequiredPermissionsLinksTest.php | 4 +- 40 files changed, 1558 insertions(+), 149 deletions(-) create mode 100644 app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php create mode 100644 app/Providers/Filament/TenantPanelProvider.php create mode 100644 specs/080-workspace-managed-tenant-admin/contracts/openapi.yaml create mode 100644 specs/080-workspace-managed-tenant-admin/contracts/routes.md create mode 100644 specs/080-workspace-managed-tenant-admin/data-model.md create mode 100644 specs/080-workspace-managed-tenant-admin/plan.md create mode 100644 specs/080-workspace-managed-tenant-admin/quickstart.md create mode 100644 specs/080-workspace-managed-tenant-admin/research.md create mode 100644 specs/080-workspace-managed-tenant-admin/spec.md create mode 100644 specs/080-workspace-managed-tenant-admin/tasks.md create mode 100644 tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index d9072f6..567925f 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -18,6 +18,8 @@ ## Active Technologies - PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard) - PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical) - PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical) +- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin) +- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin) - PHP 8.4.15 (feat/005-bulk-operations) @@ -37,9 +39,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 - 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) - 078-operations-tenantless-canonical: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] -- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x diff --git a/app/Filament/Pages/ChooseTenant.php b/app/Filament/Pages/ChooseTenant.php index 05ef5f5..f4bedc3 100644 --- a/app/Filament/Pages/ChooseTenant.php +++ b/app/Filament/Pages/ChooseTenant.php @@ -72,7 +72,7 @@ public function selectTenant(int $tenantId): void app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request()); - $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + $this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); } private function persistLastTenant(User $user, Tenant $tenant): void diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index 32e14d9..9b18a1d 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -177,7 +177,7 @@ private function redirectAfterWorkspaceSelected(User $user): string $tenant = $tenantsQuery->first(); if ($tenant !== null) { - return TenantDashboard::getUrl(tenant: $tenant); + return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); } } diff --git a/app/Filament/Pages/Monitoring/Alerts.php b/app/Filament/Pages/Monitoring/Alerts.php index 3391709..31b4305 100644 --- a/app/Filament/Pages/Monitoring/Alerts.php +++ b/app/Filament/Pages/Monitoring/Alerts.php @@ -10,6 +10,8 @@ class Alerts extends Page { + protected static bool $isDiscovered = false; + protected static bool $shouldRegisterNavigation = false; protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; diff --git a/app/Filament/Pages/Monitoring/AuditLog.php b/app/Filament/Pages/Monitoring/AuditLog.php index 7f05dc5..31794c2 100644 --- a/app/Filament/Pages/Monitoring/AuditLog.php +++ b/app/Filament/Pages/Monitoring/AuditLog.php @@ -10,6 +10,8 @@ class AuditLog extends Page { + protected static bool $isDiscovered = false; + protected static bool $shouldRegisterNavigation = false; protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; diff --git a/app/Filament/Pages/TenantDashboard.php b/app/Filament/Pages/TenantDashboard.php index 53bf2d9..defca63 100644 --- a/app/Filament/Pages/TenantDashboard.php +++ b/app/Filament/Pages/TenantDashboard.php @@ -11,9 +11,18 @@ use Filament\Pages\Dashboard; use Filament\Widgets\Widget; use Filament\Widgets\WidgetConfiguration; +use Illuminate\Database\Eloquent\Model; class TenantDashboard extends Dashboard { + /** + * @param array $parameters + */ + public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string + { + return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters); + } + /** * @return array | WidgetConfiguration> */ diff --git a/app/Filament/Pages/TenantRequiredPermissions.php b/app/Filament/Pages/TenantRequiredPermissions.php index 2503176..1c874b8 100644 --- a/app/Filament/Pages/TenantRequiredPermissions.php +++ b/app/Filament/Pages/TenantRequiredPermissions.php @@ -8,16 +8,18 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; -use App\Services\Auth\CapabilityResolver; +use App\Models\WorkspaceMembership; use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; -use App\Support\Auth\Capabilities; +use App\Support\Workspaces\WorkspaceContext; use Filament\Pages\Page; class TenantRequiredPermissions extends Page { + protected static bool $isDiscovered = false; + protected static bool $shouldRegisterNavigation = false; - protected static ?string $slug = 'required-permissions'; + protected static ?string $slug = 'tenants/{tenant}/required-permissions'; protected static ?string $title = 'Required permissions'; @@ -41,17 +43,28 @@ class TenantRequiredPermissions extends Page public static function canAccess(): bool { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - /** @var CapabilityResolver $resolver */ - $resolver = app(CapabilityResolver::class); + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); + if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) { + return false; + } + + return WorkspaceMembership::query() + ->where('workspace_id', (int) $workspaceId) + ->where('user_id', (int) $user->getKey()) + ->exists(); + } + + public function currentTenant(): ?Tenant + { + return static::resolveScopedTenant(); } public function mount(): void @@ -134,7 +147,7 @@ public function resetFilters(): void private function refreshViewModel(): void { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); if (! $tenant instanceof Tenant) { $this->viewModel = []; @@ -163,7 +176,7 @@ private function refreshViewModel(): void public function reRunVerificationUrl(): ?string { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); if (! $tenant instanceof Tenant) { return null; @@ -176,9 +189,26 @@ public function reRunVerificationUrl(): ?string ->value('id'); if (! is_int($connectionId)) { - return ProviderConnectionResource::getUrl('index', tenant: $tenant); + return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); } - return ProviderConnectionResource::getUrl('edit', ['record' => $connectionId], tenant: $tenant); + return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin'); + } + + protected static function resolveScopedTenant(): ?Tenant + { + $routeTenant = request()->route('tenant'); + + if ($routeTenant instanceof Tenant) { + return $routeTenant; + } + + if (is_string($routeTenant) && $routeTenant !== '') { + return Tenant::query() + ->where('external_id', $routeTenant) + ->first(); + } + + return Tenant::current(); } } diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index a19b973..0b56986 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -54,7 +54,6 @@ use Filament\Schemas\Schema; use Filament\Support\Enums\Width; use Filament\Support\Exceptions\Halt; -use Illuminate\Contracts\View\View; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -785,25 +784,25 @@ private function verificationReportViewData(): array $verificationReport = VerificationReportViewer::report($run); return [ - 'run' => [ - 'id' => (int) $run->getKey(), - 'type' => (string) $run->type, - 'status' => (string) $run->status, - 'outcome' => (string) $run->outcome, - 'initiator_name' => (string) $run->initiator_name, - 'started_at' => $run->started_at?->toJSON(), - 'completed_at' => $run->completed_at?->toJSON(), - 'target_scope' => $targetScope, - 'failures' => $failures, - ], - 'runUrl' => $runUrl, - 'report' => $report, - 'fingerprint' => $fingerprint, - 'changeIndicator' => $changeIndicator, - 'previousRunUrl' => $previousRunUrl, - 'canAcknowledge' => $canAcknowledge, - 'acknowledgements' => $acknowledgements, - ]; + 'run' => [ + 'id' => (int) $run->getKey(), + 'type' => (string) $run->type, + 'status' => (string) $run->status, + 'outcome' => (string) $run->outcome, + 'initiator_name' => (string) $run->initiator_name, + 'started_at' => $run->started_at?->toJSON(), + 'completed_at' => $run->completed_at?->toJSON(), + 'target_scope' => $targetScope, + 'failures' => $failures, + ], + 'runUrl' => $runUrl, + 'report' => $report, + 'fingerprint' => $fingerprint, + 'changeIndicator' => $changeIndicator, + 'previousRunUrl' => $previousRunUrl, + 'canAcknowledge' => $canAcknowledge, + 'acknowledgements' => $acknowledgements, + ]; } public function acknowledgeVerificationCheckAction(): Action @@ -1837,7 +1836,7 @@ public function completeOnboarding(): void resourceId: (string) $tenant->getKey(), ); - $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + $this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); } private function verificationRun(): ?OperationRun diff --git a/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php index fd6405d..85a5b1c 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php @@ -74,6 +74,6 @@ public function openTenant(int $tenantId): void abort(404); } - $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + $this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); } } diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index 30ca9be..fa8d675 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -2,7 +2,6 @@ namespace App\Filament\Resources; -use App\Filament\Concerns\ScopesGlobalSearchToTenant; use App\Filament\Resources\ProviderConnectionResource\Pages; use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderInventorySyncJob; @@ -32,16 +31,21 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use UnitEnum; class ProviderConnectionResource extends Resource { - use ScopesGlobalSearchToTenant; + protected static bool $isDiscovered = false; protected static bool $isScopedToTenant = false; protected static ?string $model = ProviderConnection::class; + protected static ?string $slug = 'tenants/{tenant}/provider-connections'; + + protected static bool $isGloballySearchable = false; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link'; protected static string|UnitEnum|null $navigationGroup = 'Providers'; @@ -52,7 +56,7 @@ class ProviderConnectionResource extends Resource protected static function hasTenantCapability(string $capability): bool { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { @@ -66,6 +70,23 @@ protected static function hasTenantCapability(string $capability): bool && $resolver->can($user, $tenant, $capability); } + protected static function resolveScopedTenant(): ?Tenant + { + $routeTenant = request()->route('tenant'); + + if ($routeTenant instanceof Tenant) { + return $routeTenant; + } + + if (is_string($routeTenant) && $routeTenant !== '') { + return Tenant::query() + ->where('external_id', $routeTenant) + ->first(); + } + + return Tenant::current(); + } + public static function form(Schema $schema): Schema { return $schema @@ -101,7 +122,7 @@ public static function table(Table $table): Table return $table ->modifyQueryUsing(function (Builder $query): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - $tenantId = Tenant::current()?->getKey(); + $tenantId = static::resolveScopedTenant()?->getKey(); if ($workspaceId === null) { return $query->whereRaw('1 = 0'); @@ -184,7 +205,7 @@ public static function table(Table $table): Table ->color('success') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, StartVerification $verification): void { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant) { @@ -254,7 +275,7 @@ public static function table(Table $table): Table ->color('info') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { @@ -331,7 +352,7 @@ public static function table(Table $table): Table ->color('info') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { @@ -408,7 +429,7 @@ public static function table(Table $table): Table ->color('primary') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); if (! $tenant instanceof Tenant) { return; @@ -466,7 +487,7 @@ public static function table(Table $table): Table ->maxLength(255), ]) ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); if (! $tenant instanceof Tenant) { return; @@ -516,7 +537,7 @@ public static function table(Table $table): Table ->color('success') ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); if (! $tenant instanceof Tenant) { return; @@ -587,7 +608,7 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + $tenant = static::resolveScopedTenant(); if (! $tenant instanceof Tenant) { return; @@ -642,7 +663,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - $tenantId = Tenant::current()?->getKey(); + $tenantId = static::resolveScopedTenant()?->getKey(); $query = parent::getEloquentQuery(); @@ -664,4 +685,20 @@ public static function getPages(): array 'edit' => Pages\EditProviderConnection::route('/{record}/edit'), ]; } + + /** + * @param array $parameters + */ + public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string + { + if (! array_key_exists('tenant', $parameters)) { + $resolvedTenant = static::resolveScopedTenant(); + + if ($resolvedTenant instanceof Tenant) { + $parameters['tenant'] = $resolvedTenant->external_id; + } + } + + return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters); + } } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php index e5a3306..9029ab2 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php @@ -17,7 +17,11 @@ class CreateProviderConnection extends CreateRecord protected function mutateFormDataBeforeCreate(array $data): array { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); + + if (! $tenant instanceof Tenant) { + abort(404); + } $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); @@ -33,7 +37,12 @@ protected function mutateFormDataBeforeCreate(array $data): array protected function afterCreate(): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + $record = $this->getRecord(); $user = auth()->user(); @@ -72,4 +81,21 @@ protected function afterCreate(): void ->success() ->send(); } + + private function currentTenant(): ?Tenant + { + $tenant = request()->route('tenant'); + + if ($tenant instanceof Tenant) { + return $tenant; + } + + if (is_string($tenant) && $tenant !== '') { + return Tenant::query() + ->where('external_id', $tenant) + ->first(); + } + + return Tenant::current(); + } } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 6afef09..dbe9470 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -42,7 +42,7 @@ protected function mutateFormDataBeforeSave(array $data): array protected function afterSave(): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $record = $this->getRecord(); $changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at'])); @@ -109,7 +109,7 @@ protected function afterSave(): void protected function getHeaderActions(): array { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); return [ Actions\DeleteAction::make() @@ -128,7 +128,7 @@ protected function getHeaderActions(): array ->where('context->provider_connection_id', (int) $record->getKey()) ->exists()) ->url(function (ProviderConnection $record): ?string { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant) { return null; @@ -159,7 +159,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-check-badge') ->color('success') ->visible(function (ProviderConnection $record): bool { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $user = auth()->user(); return $tenant instanceof Tenant @@ -168,7 +168,7 @@ protected function getHeaderActions(): array && $record->status !== 'disabled'; }) ->action(function (ProviderConnection $record, StartVerification $verification): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant) { @@ -256,7 +256,7 @@ protected function getHeaderActions(): array ->maxLength(255), ]) ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant) { abort(404); @@ -314,7 +314,7 @@ protected function getHeaderActions(): array ->where('provider', $record->provider) ->count() > 1) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant) { abort(404); @@ -361,7 +361,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-arrow-path') ->color('info') ->visible(function (ProviderConnection $record): bool { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $user = auth()->user(); return $tenant instanceof Tenant @@ -370,7 +370,7 @@ protected function getHeaderActions(): array && $record->status !== 'disabled'; }) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant) { @@ -455,7 +455,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-shield-check') ->color('info') ->visible(function (ProviderConnection $record): bool { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $user = auth()->user(); return $tenant instanceof Tenant @@ -464,7 +464,7 @@ protected function getHeaderActions(): array && $record->status !== 'disabled'; }) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant) { @@ -550,7 +550,7 @@ protected function getHeaderActions(): array ->color('success') ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant) { return; @@ -622,7 +622,7 @@ protected function getHeaderActions(): array ->requiresConfirmation() ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant) { return; @@ -676,7 +676,7 @@ protected function getHeaderActions(): array protected function getFormActions(): array { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $user = auth()->user(); @@ -699,7 +699,7 @@ protected function getFormActions(): array protected function handleRecordUpdate(Model $record, array $data): Model { - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $user = auth()->user(); @@ -719,4 +719,21 @@ protected function handleRecordUpdate(Model $record, array $data): Model return parent::handleRecordUpdate($record, $data); } + + private function currentTenant(): ?Tenant + { + $tenant = request()->route('tenant'); + + if ($tenant instanceof Tenant) { + return $tenant; + } + + if (is_string($tenant) && $tenant !== '') { + return Tenant::query() + ->where('external_id', $tenant) + ->first(); + } + + return Tenant::current(); + } } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 386b02a..661ae4b 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -58,8 +58,14 @@ class TenantResource extends Resource // ... [Properties Omitted for Brevity] ... protected static ?string $model = Tenant::class; + protected static bool $isDiscovered = false; + protected static bool $isScopedToTenant = false; + protected static ?string $recordTitleAttribute = 'name'; + + protected static ?string $recordRouteKeyName = 'external_id'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2'; protected static string|UnitEnum|null $navigationGroup = 'Settings'; @@ -286,7 +292,7 @@ public static function table(Table $table): Table Actions\Action::make('view') ->label('View') ->icon('heroicon-o-eye') - ->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)), + ->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])), UiEnforcement::forAction( Actions\Action::make('syncTenant') ->label('Sync') @@ -405,13 +411,13 @@ public static function table(Table $table): Table ->label('Open') ->icon('heroicon-o-arrow-right') ->color('primary') - ->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record)) + ->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record)) ->visible(fn (Tenant $record) => $record->isActive()), UiEnforcement::forAction( Actions\Action::make('edit') ->label('Edit') ->icon('heroicon-o-pencil-square') - ->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record)) + ->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record])) ) ->requireCapability(Capabilities::TENANT_MANAGE) ->apply(), @@ -801,6 +807,7 @@ public static function getPages(): array 'create' => Pages\CreateTenant::route('/create'), 'view' => Pages\ViewTenant::route('/{record}'), 'edit' => Pages\EditTenant::route('/{record}/edit'), + 'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'), ]; } @@ -942,7 +949,6 @@ public static function rbacAction(): Actions\Action ->url(route('admin.rbac.start', [ 'tenant' => $record->graphTenantId(), 'return' => route('filament.admin.resources.tenants.view', [ - 'tenant' => $record->external_id, 'record' => $record, ]), ])), @@ -1082,7 +1088,6 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti ->url(route('admin.rbac.start', [ 'tenant' => $tenant->graphTenantId(), 'return' => route('filament.admin.resources.tenants.view', [ - 'tenant' => $tenant->external_id, 'record' => $tenant, ]), ])); @@ -1272,7 +1277,6 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act ->url(route('admin.rbac.start', [ 'tenant' => $tenant->graphTenantId(), 'return' => route('filament.admin.resources.tenants.view', [ - 'tenant' => $tenant->external_id, 'record' => $tenant, ]), ])); diff --git a/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php b/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php new file mode 100644 index 0000000..357670a --- /dev/null +++ b/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php @@ -0,0 +1,8 @@ +label('Provider connections') + ->icon('heroicon-o-link') + ->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant' => $record->external_id], panel: 'admin')) + ) + ->requireCapability(Capabilities::PROVIDER_VIEW) + ->apply(), UiEnforcement::forAction( Actions\Action::make('edit') ->label('Edit') diff --git a/app/Http/Controllers/SelectTenantController.php b/app/Http/Controllers/SelectTenantController.php index edc5701..880413a 100644 --- a/app/Http/Controllers/SelectTenantController.php +++ b/app/Http/Controllers/SelectTenantController.php @@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request); - return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); } private function persistLastTenant(User $user, Tenant $tenant): void diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php index 4a28801..956077d 100644 --- a/app/Http/Controllers/SwitchWorkspaceController.php +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -65,7 +65,7 @@ public function __invoke(Request $request): RedirectResponse $tenant = $tenantsQuery->first(); if ($tenant !== null) { - return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); } } diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index 8ebd8f2..7c634da 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -65,6 +65,10 @@ public function handle(Request $request, Closure $next): Response $canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class); + if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) { + abort(404); + } + $target = ($hasAnyActiveMembership || $canCreateWorkspace) ? '/admin/choose-workspace' : '/admin/no-access'; diff --git a/app/Jobs/ProviderConnectionHealthCheckJob.php b/app/Jobs/ProviderConnectionHealthCheckJob.php index 655454f..a3a7f72 100644 --- a/app/Jobs/ProviderConnectionHealthCheckJob.php +++ b/app/Jobs/ProviderConnectionHealthCheckJob.php @@ -10,9 +10,9 @@ use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Intune\TenantPermissionService; use App\Services\OperationRunService; -use App\Services\Providers\ProviderGateway; use App\Services\Providers\Contracts\HealthResult; use App\Services\Providers\MicrosoftProviderHealthCheck; +use App\Services\Providers\ProviderGateway; use App\Support\Audit\AuditActionId; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -91,15 +91,15 @@ public function handle( $permissionService = app(TenantPermissionService::class); - $graphOptions = null; + $graphOptions = null; - if ($result->healthy) { - try { - $graphOptions = app(ProviderGateway::class)->graphOptions($connection); - } catch (\Throwable) { - $graphOptions = null; - } + if ($result->healthy) { + try { + $graphOptions = app(ProviderGateway::class)->graphOptions($connection); + } catch (\Throwable) { + $graphOptions = null; } + } $permissionComparison = $result->healthy ? ($graphOptions === null @@ -204,8 +204,9 @@ public function handle( : [[ 'label' => 'Review provider connection', 'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [ + 'tenant' => $tenant, 'record' => (int) $connection->getKey(), - ], tenant: $tenant), + ], panel: 'admin'), ]], ], ...$permissionChecks, diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index e5ae34a..f8a8a78 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -177,6 +177,11 @@ public static function currentOrFail(): self return $tenant; } + public function getRouteKeyName(): string + { + return 'external_id'; + } + public function resolveRouteBinding($value, $field = null): ?Model { $field ??= $this->getRouteKeyName(); diff --git a/app/Policies/ProviderConnectionPolicy.php b/app/Policies/ProviderConnectionPolicy.php index a618895..03300cd 100644 --- a/app/Policies/ProviderConnectionPolicy.php +++ b/app/Policies/ProviderConnectionPolicy.php @@ -22,7 +22,7 @@ public function viewAny(User $user): bool return false; } - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); return $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspace->getKey() @@ -36,7 +36,7 @@ public function view(User $user, ProviderConnection $connection): Response|bool return Response::denyAsNotFound(); } - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -64,7 +64,7 @@ public function create(User $user): bool return false; } - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); return $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspace->getKey() @@ -78,7 +78,7 @@ public function update(User $user, ProviderConnection $connection): Response|boo return Response::denyAsNotFound(); } - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -106,7 +106,7 @@ public function delete(User $user, ProviderConnection $connection): Response|boo return Response::denyAsNotFound(); } - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -135,4 +135,21 @@ private function currentWorkspace(): ?Workspace ? Workspace::query()->whereKey($workspaceId)->first() : null; } + + private function currentTenant(): ?Tenant + { + $tenant = request()->route('tenant'); + + if ($tenant instanceof Tenant) { + return $tenant; + } + + if (is_string($tenant) && $tenant !== '') { + return Tenant::query() + ->where('external_id', $tenant) + ->first(); + } + + return Tenant::current(); + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f6a363e..874099d 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -6,15 +6,14 @@ use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\NoAccess; -use App\Filament\Pages\TenantDashboard; +use App\Filament\Pages\TenantRequiredPermissions; +use App\Filament\Resources\ProviderConnectionResource; +use App\Filament\Resources\TenantResource; use App\Filament\Resources\Workspaces\WorkspaceResource; -use App\Models\Tenant; use App\Models\User; use App\Models\WorkspaceMembership; use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; -use App\Support\Middleware\DenyNonMemberTenantAccess; -use Filament\Facades\Filament; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -38,7 +37,6 @@ class AdminPanelProvider extends PanelProvider public function panel(Panel $panel): Panel { $panel = $panel - ->default() ->id('admin') ->path('admin') ->login(Login::class) @@ -49,10 +47,6 @@ public function panel(Panel $panel): Panel WorkspaceResource::registerRoutes($panel); }) - ->tenant(Tenant::class, slugAttribute: 'external_id') - ->tenantRoutePrefix('t') - ->tenantMenu(fn (): bool => filled(Filament::getTenant())) - ->searchableTenantMenu() ->colors([ 'primary' => Color::Amber, ]) @@ -108,13 +102,13 @@ public function panel(Panel $panel): Panel ? view('livewire.bulk-operation-progress-wrapper')->render() : '' ) - ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters') - ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') - ->pages([ - TenantDashboard::class, + ->resources([ + TenantResource::class, + ProviderConnectionResource::class, + ]) + ->pages([ + TenantRequiredPermissions::class, ]) - ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') ->widgets([ AccountWidget::class, FilamentInfoWidget::class, @@ -130,8 +124,6 @@ public function panel(Panel $panel): Panel SubstituteBindings::class, 'ensure-correct-guard:web', 'ensure-workspace-selected', - 'ensure-filament-tenant-selected', - DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php new file mode 100644 index 0000000..bfd280b --- /dev/null +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -0,0 +1,94 @@ +default() + ->id('tenant') + ->path('admin/t') + ->login(Login::class) + ->tenant(Tenant::class, slugAttribute: 'external_id') + ->tenantRoutePrefix(null) + ->tenantMenu(fn (): bool => filled(Filament::getTenant())) + ->searchableTenantMenu() + ->colors([ + 'primary' => Color::Amber, + ]) + ->renderHook( + PanelsRenderHook::HEAD_END, + fn () => view('filament.partials.livewire-intercept-shim')->render() + ) + ->renderHook( + PanelsRenderHook::TOPBAR_START, + fn () => view('filament.partials.context-bar')->render() + ) + ->renderHook( + PanelsRenderHook::BODY_END, + fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true) + ? view('livewire.bulk-operation-progress-wrapper')->render() + : '' + ) + ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters') + ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') + ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') + ->pages([ + TenantDashboard::class, + ]) + ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') + ->widgets([ + AccountWidget::class, + FilamentInfoWidget::class, + ]) + ->databaseNotifications() + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + 'ensure-correct-guard:web', + 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', + DenyNonMemberTenantAccess::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]); + + if (! app()->runningUnitTests()) { + $panel->viteTheme('resources/css/filament/admin/theme.css'); + } + + return $panel; + } +} diff --git a/app/Support/Links/RequiredPermissionsLinks.php b/app/Support/Links/RequiredPermissionsLinks.php index 5d27014..df70fe1 100644 --- a/app/Support/Links/RequiredPermissionsLinks.php +++ b/app/Support/Links/RequiredPermissionsLinks.php @@ -14,7 +14,7 @@ final class RequiredPermissionsLinks */ public static function requiredPermissions(Tenant $tenant, array $filters = []): string { - $base = sprintf('/admin/t/%s/required-permissions', urlencode((string) $tenant->external_id)); + $base = sprintf('/admin/tenants/%s/required-permissions', urlencode((string) $tenant->external_id)); if ($filters === []) { return $base; diff --git a/app/Support/OperationRunLinks.php b/app/Support/OperationRunLinks.php index 3746405..6f26d0e 100644 --- a/app/Support/OperationRunLinks.php +++ b/app/Support/OperationRunLinks.php @@ -50,50 +50,50 @@ public static function related(OperationRun $run, ?Tenant $tenant): array $providerConnectionId = $context['provider_connection_id'] ?? null; if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) { - $links['Provider Connections'] = ProviderConnectionResource::getUrl('index', tenant: $tenant); - $links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['record' => (int) $providerConnectionId], tenant: $tenant); + $links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); + $links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin'); } if ($run->type === 'inventory.sync') { - $links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant); + $links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant); } if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) { - $links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant); + $links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant); $policyId = $context['policy_id'] ?? null; if (is_numeric($policyId)) { - $links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant); + $links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant); } } if ($run->type === 'directory_groups.sync') { - $links['Directory Groups'] = EntraGroupResource::getUrl('index', tenant: $tenant); + $links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant); } if ($run->type === 'drift.generate') { - $links['Drift'] = DriftLanding::getUrl(tenant: $tenant); + $links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant); } if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) { - $links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant); + $links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant); $backupSetId = $context['backup_set_id'] ?? null; if (is_numeric($backupSetId)) { - $links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant); + $links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant); } } if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) { - $links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant); + $links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant); } if ($run->type === 'restore.execute') { - $links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant); + $links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant); $restoreRunId = $context['restore_run_id'] ?? null; if (is_numeric($restoreRunId)) { - $links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant); + $links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant); } } diff --git a/bootstrap/providers.php b/bootstrap/providers.php index d76f840..93e3aac 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -4,5 +4,6 @@ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, + App\Providers\Filament\TenantPanelProvider::class, App\Providers\Filament\SystemPanelProvider::class, ]; diff --git a/resources/views/filament/pages/tenant-required-permissions.blade.php b/resources/views/filament/pages/tenant-required-permissions.blade.php index ab6a9ec..68f5cb8 100644 --- a/resources/views/filament/pages/tenant-required-permissions.blade.php +++ b/resources/views/filament/pages/tenant-required-permissions.blade.php @@ -1,10 +1,9 @@ @php - use App\Models\Tenant; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Links\RequiredPermissionsLinks; - $tenant = Tenant::current(); + $tenant = $this->currentTenant(); $vm = is_array($viewModel ?? null) ? $viewModel : []; $overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : []; diff --git a/resources/views/filament/partials/context-bar.blade.php b/resources/views/filament/partials/context-bar.blade.php index ff860ea..f72df59 100644 --- a/resources/views/filament/partials/context-bar.blade.php +++ b/resources/views/filament/partials/context-bar.blade.php @@ -65,7 +65,7 @@ Switch workspace diff --git a/routes/web.php b/routes/web.php index fe610fa..2ab4541 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,7 +10,6 @@ use App\Http\Controllers\TenantOnboardingController; use App\Models\User; use App\Models\Workspace; -use App\Support\Middleware\DenyNonMemberTenantAccess; use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceResolver; use Filament\Http\Middleware\Authenticate as FilamentAuthenticate; @@ -35,7 +34,6 @@ 'web', 'panel:admin', 'ensure-correct-guard:web', - DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, FilamentAuthenticate::class, @@ -74,7 +72,7 @@ $tenant = $tenantsQuery->first(); if ($tenant !== null) { - return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); } } @@ -141,26 +139,10 @@ 'web', 'panel:admin', 'ensure-correct-guard:web', - DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', - 'ensure-filament-tenant-selected', -]) - ->get('/admin/t/{tenant}/operations', fn () => redirect()->route('admin.operations.index')) - ->name('admin.operations.legacy-index'); - -Route::middleware([ - 'web', - 'panel:admin', - 'ensure-correct-guard:web', - DenyNonMemberTenantAccess::class, - DisableBladeIconComponents::class, - DispatchServingFilamentEvent::class, - FilamentAuthenticate::class, - 'ensure-workspace-selected', - 'ensure-filament-tenant-selected', ]) ->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class) ->name('admin.operations.index'); @@ -169,12 +151,10 @@ 'web', 'panel:admin', 'ensure-correct-guard:web', - DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', - 'ensure-filament-tenant-selected', ]) ->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class) ->name('admin.monitoring.alerts'); @@ -183,12 +163,10 @@ 'web', 'panel:admin', 'ensure-correct-guard:web', - DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', - 'ensure-filament-tenant-selected', ]) ->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class) ->name('admin.monitoring.audit-log'); @@ -197,11 +175,10 @@ 'web', 'panel:admin', 'ensure-correct-guard:web', - DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, FilamentAuthenticate::class, - 'ensure-filament-tenant-selected', + 'ensure-workspace-selected', ]) ->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class) ->name('admin.operations.view'); @@ -210,12 +187,10 @@ 'web', 'panel:admin', 'ensure-correct-guard:web', - DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-member', - 'ensure-filament-tenant-selected', ]) ->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class) ->name('admin.workspace.managed-tenants.index'); diff --git a/specs/080-workspace-managed-tenant-admin/contracts/openapi.yaml b/specs/080-workspace-managed-tenant-admin/contracts/openapi.yaml new file mode 100644 index 0000000..0492316 --- /dev/null +++ b/specs/080-workspace-managed-tenant-admin/contracts/openapi.yaml @@ -0,0 +1,45 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin Context APIs (Spec 080) + version: 0.1.0 + description: | + Minimal HTTP contract for non-Filament endpoints involved in workspace/tenant context selection. + + Filament page/resource routes are not fully described here because they are generated by Filament. + The spec’s primary contract for those is the route map in `routes.md`. + +paths: + /admin/switch-workspace: + post: + summary: Switch the active workspace context + responses: + '204': { description: Workspace switched } + '302': { description: Redirect (if implemented) } + '401': { description: Unauthenticated } + '404': { description: Not a workspace member (deny-as-not-found) } + + /admin/select-tenant: + post: + summary: Select the active tenant context within the selected workspace + responses: + '204': { description: Tenant selected } + '302': { description: Redirect (if implemented) } + '401': { description: Unauthenticated } + '404': { description: Not entitled to tenant (deny-as-not-found) } + + /admin/clear-tenant-context: + post: + summary: Clear the active tenant context + responses: + '204': { description: Tenant context cleared } + '302': { description: Redirect (if implemented) } + '401': { description: Unauthenticated } + +components: + securitySchemes: + SessionAuth: + type: apiKey + in: cookie + name: tenantpilot_session +security: + - SessionAuth: [] diff --git a/specs/080-workspace-managed-tenant-admin/contracts/routes.md b/specs/080-workspace-managed-tenant-admin/contracts/routes.md new file mode 100644 index 0000000..d418abd --- /dev/null +++ b/specs/080-workspace-managed-tenant-admin/contracts/routes.md @@ -0,0 +1,52 @@ +# Route Contract — Spec 080 + +This document defines the **expected user-facing route surfaces** and the **required 404/403 semantics**. + +## Canonical Management (workspace-scoped) + +All of the following are under `/admin/*` and require: +- selected workspace context +- workspace membership (non-member → 404) + +Routes: +- `GET /admin/tenants` +- `GET /admin/tenants/{tenant}` +- `GET /admin/tenants/{tenant}/memberships` +- `GET /admin/tenants/{tenant}/provider-connections` +- `GET /admin/tenants/{tenant}/provider-connections/{connection}/edit` +- `GET /admin/tenants/{tenant}/required-permissions` +- (optional) `GET /admin/tenants/{tenant}/onboarding` + +Identifier contract: +- `{tenant}` MUST be `Tenant.external_id` (Entra tenant GUID) + +Authorization contract: +- member without capability: + - viewing pages: allowed + - mutating actions: 403 + +## Canonical Operate (tenant-scoped) + +All of the following are under `/admin/t/{tenant}/*` and require: +- selected workspace context +- workspace membership +- tenant entitlement (non-entitled → 404) + +Routes (contract targets for US2 tests): +- `GET /admin/t/{tenant}` (tenant dashboard root) +- `GET /admin/t/{tenant}/diagnostics` (operational diagnostics page) + +## Removed Tenant-Scoped Management (must 404) + +The following routes MUST NOT exist (no redirects in dev stage): +- `GET /admin/t/{tenant}/provider-connections*` +- `GET /admin/t/{tenant}/required-permissions*` +- `GET /admin/t/{tenant}/memberships*` +- `GET /admin/t/{tenant}/tenants*` + +## Monitoring + +- `GET /admin/operations` +- `GET /admin/operations/{run}` + +Monitoring pages are DB-only at render time. diff --git a/specs/080-workspace-managed-tenant-admin/data-model.md b/specs/080-workspace-managed-tenant-admin/data-model.md new file mode 100644 index 0000000..f843d08 --- /dev/null +++ b/specs/080-workspace-managed-tenant-admin/data-model.md @@ -0,0 +1,71 @@ +# Data Model — Spec 080 Workspace-Managed Tenant Administration Migration + +This feature is primarily a **routing + panel registration** change. No new entities are required, but the plan relies on these existing domain objects and their relationships. + +## Entities + +### Workspace +- Represents the portfolio/customer context. +- Key fields (typical): `id`, `name`, `slug` or `uuid`, `archived_at`, timestamps. + +### WorkspaceMembership +- Joins a `User` to a `Workspace` with a role. +- Key fields: `id`, `workspace_id`, `user_id`, `role`, timestamps. +- Rules: + - Workspace membership is an isolation boundary for `/admin/*` management. + +### Tenant (Managed Tenant) +- Workspace-owned representation of an Entra/Intune tenant. +- Key fields (from usage in the codebase): + - `id` + - `workspace_id` + - `external_id` (canonical route identifier; Entra tenant GUID) + - `tenant_id` (Entra tenant ID / GUID — may be same domain meaning depending on model) + - `name`, `domain`, `environment` + - `metadata` (JSON) + - `archived_at` (if supported) + - timestamps +- Notes: + - `{tenant}` route parameter refers to `Tenant.external_id` in both `/admin/tenants/{tenant}` and `/admin/t/{tenant}`. + +### TenantMembership +- Joins a `User` to a `Tenant` with a tenant role. +- Key fields: `id`, `tenant_id`, `user_id`, `role`, timestamps. +- Rules: + - Tenant membership is an isolation boundary for `/admin/t/{tenant}/*`. + - Guardrails: cannot remove/demote the last Owner (existing rule in constitution and code). + +### ProviderConnection +- Stores provider integration configuration for a managed tenant. +- Key fields (from resource usage): + - `id`, `workspace_id`, `tenant_id` + - `provider` + - `display_name` + - `entra_tenant_id` + - `is_default` + - `status`, `health_status` + - timestamps +- Notes: + - Treated as workspace-managed configuration, but scoped to a specific managed tenant via FK. + +### AuditLog +- Append-only record of security/management events. +- Required attributes (per spec): `workspace_id`, `tenant_id`, `actor_id`, `action_id`, redacted metadata, timestamp. + +### OperationRun +- Existing observability record for long-running operations. +- This migration itself should not introduce new runs; management page renders must be DB-only. + +## Relationships (high level) + +- Workspace 1—* WorkspaceMembership +- Workspace 1—* Tenant +- Tenant 1—* TenantMembership +- Tenant 1—* ProviderConnection +- Workspace 1—* ProviderConnection +- Workspace/Tenant 1—* AuditLog + +## State & Transitions + +- This feature does not add new domain state transitions. +- Any existing onboarding/activation state changes remain workspace-managed in UI (per spec) and must continue to be audited. diff --git a/specs/080-workspace-managed-tenant-admin/plan.md b/specs/080-workspace-managed-tenant-admin/plan.md new file mode 100644 index 0000000..a486a54 --- /dev/null +++ b/specs/080-workspace-managed-tenant-admin/plan.md @@ -0,0 +1,188 @@ +# Implementation Plan: Spec 080 Workspace-Managed Tenant Administration Migration + +**Branch**: `080-workspace-managed-tenant-admin` | **Date**: 2026-02-07 | **Spec**: [/specs/080-workspace-managed-tenant-admin/spec.md](spec.md) +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Migrate tenant administration surfaces out of tenant scope (`/admin/t/{tenant}/*`) into workspace scope (`/admin/*`) and keep tenant scope strictly for operational modules. + +Implementation strategy: +- Introduce a second Filament panel for tenant operations at `/admin/t/{tenant}`. +- Convert the existing admin panel at `/admin` into a tenantless workspace management panel. +- Register management resources/pages only in the workspace panel, ensuring tenant-scoped management routes are not registered (404). +- Rewire internal CTAs/links (onboarding, required permissions, provider connection edit) to the new canonical workspace routes. + +## Technical Context + +**Language/Version**: PHP 8.4.15 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Tailwind v4 +**Storage**: PostgreSQL (via Sail) +**Testing**: Pest v4 (PHPUnit v12 runner) +**Target Platform**: Web application (server-rendered Filament/Livewire) +**Project Type**: Laravel monolith +**Performance Goals**: No new performance targets; management viewers must remain DB-only at render time +**Constraints**: +- No external calls during render for management viewers (DB-only). +- Dev-stage removed routes must 404 (no redirects). +- 404 vs 403 semantics must follow RBAC-UX. +**Scale/Scope**: Enterprise SaaS IA separation across admin surfaces (route + navigation correctness) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +Assessment (pre-Phase 0): PASS +- No new Graph calls are introduced by this migration. +- No new long-running operations are introduced. +- Authorization behavior is being tightened via panel separation and route registration. +- Monitoring/management viewers remain DB-only. + +Notes: +- This feature changes how routes are registered (which affects discovery, global search, and navigation). It must include regression tests ensuring removed tenant-scoped management routes do not exist. + +- 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; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks) +- 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` +- 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 + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── 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/ +│ │ ├── Workspaces/ +│ │ ├── Monitoring/ +│ │ └── … +│ ├── Resources/ +│ └── Concerns/ +├── Providers/ +│ └── Filament/ +│ ├── AdminPanelProvider.php +│ ├── TenantPanelProvider.php +│ └── SystemPanelProvider.php +├── Support/ +│ └── Middleware/ +routes/ +└── web.php + +tests/ +├── Feature/ +└── Unit/ + +bootstrap/ +└── providers.php +``` + +**Structure Decision**: Laravel monolith. Panel providers define panel boundaries. Routes outside Filament are defined in `routes/web.php`. + +## Complexity Tracking + +No constitution violations requiring justification. + +## Phase 0 — Outline & Research + +Outputs (written during `/speckit.plan`): +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/research.md` + +Research questions (all resolved): +- How to configure a tenancy panel whose URLs are `/admin/t/{tenant}` without duplicating `/t/`. +- How to enforce route removal: do not register resources/pages in the tenant panel. +- How to keep global search isolated by panel registration. + +## Phase 1 — Design & Contracts + +Outputs: +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/contracts/` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/quickstart.md` + +### Panel Design + +**Workspace panel (Manage)** +- ID: `admin` (keep existing ID to avoid breaking `panel:admin` middleware usage) +- Path: `/admin` +- Tenancy: disabled (no `->tenant(...)`) +- Registered artifacts: tenant management resources/pages, monitoring pages. + +**Tenant panel (Operate)** +- ID: `tenant` (new) +- Path: `/admin/t` +- Tenancy: enabled via `Tenant::class` with `slugAttribute: 'external_id'` +- Tenant route prefix: blank/`null` so tenant routes become `/admin/t/{tenant}/...` +- Registered artifacts: operational resources/pages only (inventory, drift, backups, policies, directory, etc.). +- Route-shape verification is mandatory: automated regression checks must assert canonical tenant URLs are `/admin/t/{tenant}` and `/admin/t/{tenant}/...` (never `/admin/t/t/{tenant}`). + +Laravel 11+ provider registration requirement: +- Register the new tenant panel provider in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/bootstrap/providers.php`. + +### Routing/Removal Mechanism + +Tenant-scoped management routes return 404 by construction: +- Management resources/pages are **not registered** in the tenant panel. +- No redirects (dev-stage). + +### Authorization Design + +- Workspace management pages: require selected workspace + membership; non-member → 404. +- Tenant operational routes: require workspace membership + tenant entitlement; non-entitled → 404. +- Mutations: capability missing → 403 (server-side policy/gate); destructive-like actions require `->requiresConfirmation()`. + +### Global Search Design + +- Workspace panel: managed tenants are searchable (ensure resource has Edit/View page). +- Tenant panel: tenant-management entities are not registered, so they cannot appear in global search. + +## Phase 2 — Implementation Plan (Code + Tests) + +Stop condition for `/speckit.plan`: this section outlines implementation, but actual task breakdown happens in `/speckit.tasks`. + +Planned steps: +1. Add new panel provider for tenant operations (e.g., `App\Providers\Filament\TenantPanelProvider`). +2. Register provider in `bootstrap/providers.php` (Laravel 11+ pattern). +3. Refactor `AdminPanelProvider` into a tenantless workspace panel: + - remove tenancy configuration (`->tenant(...)`, tenant menu, tenant route prefix) + - remove tenant-only middleware from the workspace panel pipeline +4. Move/register management pages/resources into workspace panel: + - `TenantResource` (managed tenants CRUD / manage view) + - `ProviderConnectionResource` (workspace-managed connections by tenant) + - required permissions viewer page + - membership management surfaces + - onboarding/activation surfaces +5. Move/register operational pages/resources into the tenant panel. +6. Rewire internal links/CTAs that currently build tenant-scoped management URLs to the new workspace-managed canonical URLs. +7. Add regression tests (Pest) to cover: + - workspace member can access `/admin/tenants*` + - non-member gets 404 + - tenant entitlement required for `/admin/t/{tenant}/...` + - canonical tenant panel route shape is `/admin/t/{tenant}/...` (no duplicated `/t`) + - tenant-scoped management routes are missing (404) + - link rewiring expectations (where feasible) +8. Run targeted test pack and Pint: + - `vendor/bin/sail artisan test --compact tests/Feature/...` + - `vendor/bin/sail bin pint --dirty` diff --git a/specs/080-workspace-managed-tenant-admin/quickstart.md b/specs/080-workspace-managed-tenant-admin/quickstart.md new file mode 100644 index 0000000..f2454fc --- /dev/null +++ b/specs/080-workspace-managed-tenant-admin/quickstart.md @@ -0,0 +1,36 @@ +# Quickstart — Spec 080 Workspace-Managed Tenant Administration Migration + +## Prereqs + +- Laravel Sail is used for local dev. + +## Run locally + +- Start services: `vendor/bin/sail up -d` +- Install deps (if needed): `vendor/bin/sail composer install` + +## What to verify manually + +1. Select a workspace (existing flow) +2. Visit workspace-managed tenant admin: + - `/admin/tenants` + - `/admin/tenants/{tenant}` +3. Visit tenant operate routes only when entitled: + - `/admin/t/{tenant}/…` +4. Confirm removed tenant-scoped management URLs return 404: + - `/admin/t/{tenant}/provider-connections` + - `/admin/t/{tenant}/required-permissions` + +## Run targeted tests + +- Run Spec 080 test file (to be created in Phase 2): + - `vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php` + +## Formatting + +- Format touched files: `vendor/bin/sail bin pint --dirty` + +## Deployment note + +This feature changes route registration via Filament panel providers. +No migrations are expected. diff --git a/specs/080-workspace-managed-tenant-admin/research.md b/specs/080-workspace-managed-tenant-admin/research.md new file mode 100644 index 0000000..e16907e --- /dev/null +++ b/specs/080-workspace-managed-tenant-admin/research.md @@ -0,0 +1,45 @@ +# Research — Spec 080 Workspace-Managed Tenant Administration Migration + +Date: 2026-02-07 + +## Decision 1 — Two Filament panels (workspace + tenant) + +- Decision: Implement a workspace (tenantless) panel at `/admin` and a tenant (tenancy) panel at `/admin/t/{tenant}`. +- Rationale: This makes “Manage vs Operate” enforceable via route registration (removed routes 404), avoids tenant-context chicken-and-egg, and matches Filament-native separation. +- Alternatives considered: + - Keep a single panel and conditionally hide resources when tenant is selected: rejected because routes still exist and semantics are harder to enforce. + +## Decision 2 — Tenant panel path configuration to achieve `/admin/t/{tenant}` + +- Decision: Configure the tenant panel with `path('admin/t')`, `tenant(Tenant::class, slugAttribute: 'external_id')`, and **no tenant route prefix** (`tenantRoutePrefix(null)` / default). +- Rationale: Filament’s tenancy routing adds `/{tenant}` after the panel path, and the optional `tenantRoutePrefix` is only prepended when it is “filled”. Leaving it blank yields `/admin/t/{tenant}` (not `/admin/t/t/{tenant}`). +- Alternatives considered: + - Keep the existing `path('admin') + tenantRoutePrefix('t')` for a tenant panel: rejected because it would conflict with the workspace panel at the same path. + +## Decision 3 — Workspace context in URLs + +- Decision: Workspace-managed tenant management uses `/admin/tenants*` (workspace selected in session/context; enforced by middleware). +- Rationale: Matches current app pattern (`ensure-workspace-selected`) and reduces URL churn. +- Alternatives considered: + - `/admin/w/{workspace}/tenants*`: rejected because it’s not canonical for this feature and increases link surface. + +## Decision 4 — View vs mutation authorization in management scope + +- Decision: Management pages are viewable for workspace members; **mutations** are capability-gated (403). +- Rationale: Aligns with RBAC-UX guidance: membership is isolation (404), capability is authorization (403). +- Alternatives considered: + - Require capability to view: rejected to avoid “mysterious forbidden” UX and because spec explicitly reserves 403 for mutations. + +## Decision 5 — Global search isolation + +- Decision: Managed tenants are searchable in the workspace panel only; the tenant panel must not expose tenant-management entities via global search. +- Rationale: Prevents cross-scope discovery leaks and aligns with “Manage is workspace-scoped”. +- Alternatives considered: + - Disable global search entirely: rejected (spec wants workspace search behavior). + +## Decision 6 — How removed tenant-scoped management routes become 404 + +- Decision: Do not register tenant-management resources/pages in the tenant panel. +- Rationale: In Filament, unregistered resources/pages simply do not have routes; this is the cleanest dev-stage “no redirects” behavior. +- Alternatives considered: + - Redirect legacy tenant-scoped routes: rejected (explicit non-goal). diff --git a/specs/080-workspace-managed-tenant-admin/spec.md b/specs/080-workspace-managed-tenant-admin/spec.md new file mode 100644 index 0000000..8ff564f --- /dev/null +++ b/specs/080-workspace-managed-tenant-admin/spec.md @@ -0,0 +1,238 @@ +# Feature Specification: Workspace-Managed Tenant Administration Migration + +**Feature Branch**: `080-workspace-managed-tenant-admin` +**Created**: 2026-02-07 +**Status**: Draft (implementation-ready) +**Input**: User description: "Make Manage workspace-scoped (/admin) and Operate tenant-scoped (/admin/t/{tenant}). Eliminate management CRUD from /admin/t/*" + +This feature migrates all tenant administration surfaces out of the Filament tenant scope (`/admin/t/{tenant}/*`) into workspace-scoped routes (`/admin/*`). Tenant scope is reserved strictly for operational modules. + +**Separation rule (normative):** +- Workspace panel (`/admin/*`) = Manage (tenants, memberships, provider connections, required permissions, onboarding/activation, monitoring). +- Tenant panel (`/admin/t/{tenant}/*`) = Operate (inventory, drift, policies, backups, directory, etc.). + +**Out of scope for this feature:** introducing a new tenant panel “Home” page at `/admin/t/{tenant}`. + +## Clarifications + +### Session 2026-02-07 + +- Q: Should management pages be viewable for workspace members without manage capabilities? → A: Yes. Management pages are viewable for workspace members; only mutations are capability-gated (403). +- Q: Should this feature include a dedicated tenant panel “Home” page at `/admin/t/{tenant}`? → A: No. Do not add a new tenant home page in this feature. +- Q: For workspace-managed routes like `/admin/tenants/{tenant}`, what should `{tenant}` be? → A: `Tenant.external_id` (Entra tenant GUID), same identifier used by Filament tenancy under `/admin/t/{tenant}`. +- Q: Should “Managed Tenants” appear in Filament Global Search? → A: Yes, in the workspace panel only; tenant panel must not expose tenant-management entities in search. +- Q: For workspace-managed tenant management routes, what is the canonical URL shape? → A: `/admin/tenants*` (workspace is selected in context/session; middleware enforces it), not `/admin/w/{workspace}/tenants*`. + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Manage tenants from workspace scope (Priority: P1) + +As a workspace member, I can manage (view/create/configure) managed tenants under `/admin/tenants*` without needing to enter tenant scope. + +**Why this priority**: It removes the “henne-ei” context issue and makes `/admin` the canonical management surface. + +**Independent Test**: Fully testable via HTTP requests asserting 200/404 and basic page visibility under `/admin/tenants*`. + +**Acceptance Scenarios**: + +1. **Given** I am a workspace member, **When** I visit `/admin/tenants`, **Then** I can access the Tenants management list. +2. **Given** I am a workspace member, **When** I visit `/admin/tenants/{tenant}`, **Then** I can access the Tenant management overview. +3. **Given** I am not a workspace member, **When** I visit `/admin/tenants` or `/admin/tenants/{tenant}`, **Then** I receive a 404 (deny-as-not-found). + +--- + +### User Story 2 - Operate tenant modules only when entitled (Priority: P2) + +As a user, I can operate inside a managed tenant under `/admin/t/{tenant}/*` only when I’m entitled to that tenant. + +**Why this priority**: It preserves tenant isolation and makes 404/403 semantics predictable. + +**Independent Test**: Fully testable via an operational page route asserting 200 for entitled users and 404 for non-entitled users. + +**Acceptance Scenarios**: + +1. **Given** I am a workspace member and entitled to the selected tenant, **When** I visit an operational route under `/admin/t/{tenant}/*`, **Then** I can access it. +2. **Given** I am a workspace member but not entitled to the selected tenant, **When** I visit an operational route under `/admin/t/{tenant}/*`, **Then** I receive a 404 (deny-as-not-found). + +--- + +### User Story 3 - Tenant-scoped management routes are removed (Priority: P3) + +As a user, I cannot access tenant-scoped management CRUD routes anymore; they are removed and should not resolve. + +**Why this priority**: It enforces IA separation via route registration (not just UI hiding). + +**Independent Test**: Fully testable via HTTP 404 assertions for a set of removed routes. + +**Acceptance Scenarios**: + +1. **Given** any user, **When** I request `/admin/t/{tenant}/provider-connections`, **Then** I receive 404 because the route does not exist. +2. **Given** any user, **When** I request `/admin/t/{tenant}/required-permissions`, **Then** I receive 404 because the route does not exist. +3. **Given** any user, **When** I request a tenant-scoped tenant-management route (e.g. `/admin/t/{tenant}/tenants/*`), **Then** I receive 404 because the route does not exist. + +--- + +### User Story 4 - Management actions enforce capability semantics (Priority: P2) + +As a workspace member, I can see management pages, but mutations are forbidden unless I have the required capability. + +**Why this priority**: It preserves enterprise RBAC semantics (404 for non-membership; 403 for missing capability on mutation). + +**Independent Test**: Test a representative management mutation and assert 403 when capability is missing. + +**Acceptance Scenarios**: + +1. **Given** I am a workspace member without the relevant manage capability, **When** I attempt a management mutation (e.g., change role, set default connection), **Then** the server responds 403. + +--- + +### User Story 5 - Global search isolation (Priority: P2) + +As a user, global search does not leak tenant management entities across scopes. + +**Why this priority**: It prevents discovery leaks and aligns with deny-as-not-found semantics. + +**Independent Test**: Test that workspace global search can find allowed tenants; tenant panel search does not expose tenant-management entities; non-members discover nothing. + +**Acceptance Scenarios**: + +1. **Given** I am a workspace member, **When** I use workspace global search, **Then** I can find tenants I can access. +2. **Given** I am in tenant panel, **When** I use global search, **Then** it does not expose tenant-management entities. +3. **Given** I am not a workspace member, **When** I use global search, **Then** I do not discover tenant existence. + +--- + +### Edge Cases + +- Direct navigation to removed tenant-scoped management URLs. +- Stale internal CTAs/links that previously pointed at `/admin/t/{tenant}` management screens. +- Tenant slug/identifier mismatch (requesting a tenant not belonging to the active workspace context). +- Cross-scope leakage via global search results or navigation items. +- Non-member users attempting to infer tenant/workspace existence. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior, +or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates +(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. +If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. + +**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: +- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`), +- ensure any cross-plane access is deny-as-not-found (404), +- explicitly define 404 vs 403 semantics: + - non-member / not entitled to tenant scope → 404 (deny-as-not-found) + - member but missing capability → 403 +- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change, +- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code), +- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics), +- ensure destructive-like actions require confirmation (`->requiresConfirmation()`), +- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated. + +**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange) +on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages. + +**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), +the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values. + +### Functional Requirements + +**Principles (normative):** +- “Tenants” means the workspace-managed tenants list (CRUD under `/admin/tenants*`). +- “Switch tenant” is a context action in tenant scope; it must not behave like a CRUD list. +- Management must not depend on being in tenant scope; tenant scope must not expose tenant administration CRUD. + +**Routing / IA (normative):** +- Workspace management is canonical under `/admin/*`: + - `/admin/tenants` (list/create) + - `/admin/tenants/{tenant}` (manage overview) + - `/admin/tenants/{tenant}/memberships` + - `/admin/tenants/{tenant}/provider-connections` + - `/admin/tenants/{tenant}/required-permissions` + - `/admin/tenants/{tenant}/onboarding` (optional entry) + - `/admin/operations` and `/admin/operations/{run}` (monitoring) +- Tenant scope is operate-only under `/admin/t/{tenant}/*` (inventory, drift, policies, backups/restore, directory, etc.). + +**Workspace context:** +- Workspace-scoped management routes MUST use the `/admin/tenants*` shape and rely on the selected workspace context (e.g., middleware) rather than including `{workspace}` in every management URL. + +**Route parameter identity (normative):** +- `{tenant}` in both workspace-managed routes (`/admin/tenants/{tenant}/*`) and tenant-scoped routes (`/admin/t/{tenant}/*`) MUST refer to the managed tenant identifier `Tenant.external_id` (Entra tenant GUID). + +**Authorization semantics (mandatory):** +- Non-member / not entitled to scope: 404 (deny-as-not-found). +- Member lacking capability: 403 for management mutations. +- Workspace management pages are viewable for workspace members even without manage capabilities; only mutations are capability-gated. +- All checks are server-side via Policies/Gates, using the canonical capability registry (no raw strings). + +**Panel structure (native):** +- Two Filament panels are used: + - Workspace panel is tenantless and mounted at `/admin`. + - Tenant panel is tenancy-mounted at `/admin/t/{tenant}`. +- Each resource/page is registered only in the appropriate panel. + +**Global search isolation (mandatory):** +- Global search behavior is defined normatively in **FR-080-014**; this section captures intent only (no cross-scope discovery leaks). + +**Route removal (dev-stage requirement):** +- Tenant-scoped management routes must not exist after migration (expected 404): + - `/admin/t/{tenant}/provider-connections*` + - `/admin/t/{tenant}/required-permissions*` + - `/admin/t/{tenant}/memberships*` + - `/admin/t/{tenant}/tenants*` (any management tenancy list/view/edit) + +**FR-080-001**: The system MUST expose tenant administration surfaces only under workspace scope (`/admin/tenants*`). +**FR-080-002**: The system MUST expose tenant operations only under tenant scope (`/admin/t/{tenant}/*`). +**FR-080-003**: All workspace management pages MUST require active workspace context + workspace membership; non-member returns 404. +**FR-080-004**: All tenant operational routes MUST require workspace membership + tenant entitlement; non-entitled returns 404. +**FR-080-005**: Management mutations MUST return 403 when capability is missing (in addition to any UI disabling). + +**Management surfaces (workspace-scoped):** +**FR-080-006**: Tenants list and tenant manage overview MUST exist under `/admin/tenants` and `/admin/tenants/{tenant}`. +**FR-080-007**: Provider Connections CRUD MUST exist only under `/admin/tenants/{tenant}/provider-connections*`. +**FR-080-008**: Required Permissions remediation UI MUST exist only under `/admin/tenants/{tenant}/required-permissions` and render DB-only. +**FR-080-009**: Tenant Memberships/Roles management MUST exist only under `/admin/tenants/{tenant}/memberships` and audit changes. +**FR-080-010**: Activation/onboarding controls MUST live under workspace-managed tenant pages/wizard entry (not in tenant scope). + +**Navigation (enterprise):** +**FR-080-011**: Workspace navigation MUST include Tenants (manage) and Monitoring (Operations, Alerts, Audit Log). +**FR-080-012**: Tenant navigation MUST include only operational modules; no tenant CRUD entry appears in tenant sidebar. +**FR-080-014**: Workspace panel global search MAY return managed tenants only when the user can access them; tenant panel global search MUST NOT include tenant-management resources. +**FR-080-015**: Workspace-managed tenant management routes MUST be reachable under `/admin/tenants*` with a selected workspace context; `/admin/w/{workspace}` is not the canonical management route shape for this feature. + +**Filament constraint (hard rule):** any globally searchable Resource MUST have an Edit or View page; otherwise global search will return no results. + +**Observability & Audit (minimal, mandatory):** +**FR-080-013**: The system MUST emit audit events for management mutations (tenant changes, role changes, provider connection CRUD/default selection, activation/onboarding state changes) with redacted fields only. + +### Key Entities *(include if feature involves data)* + +- **Workspace**: Portfolio/customer context; primary security boundary for `/admin/*`. +- **ManagedTenant**: Workspace-owned representation of an Entra/Intune tenant (identified by Entra tenant GUID). +- **TenantMembership**: Assignment of a user to a managed tenant with a role (Owner/Manager/Operator/Readonly). +- **ProviderConnection**: Stored connection/config for provider integration, scoped to a managed tenant. +- **Capability**: Canonical capability registry entries used for authorization decisions. +- **AuditLog entry**: Append-only event record for management mutations (redacted). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-080-001**: Tenant management surfaces are reachable only under `/admin/tenants*`. +- **SC-080-002**: Tenant operational surfaces are reachable only under `/admin/t/{tenant}/*`. +- **SC-080-003**: Tenant-scoped management routes are not registered and return 404. +- **SC-080-004**: Authorization semantics are consistent: non-member 404, missing capability yields 403 on mutations. +- **SC-080-005**: Internal CTAs/links to manage provider connections/required permissions point to workspace-managed routes. diff --git a/specs/080-workspace-managed-tenant-admin/tasks.md b/specs/080-workspace-managed-tenant-admin/tasks.md new file mode 100644 index 0000000..b37abee --- /dev/null +++ b/specs/080-workspace-managed-tenant-admin/tasks.md @@ -0,0 +1,200 @@ +--- + +description: "Task breakdown for Spec 080 implementation" + +--- + +# Tasks: Workspace-Managed Tenant Administration Migration (Spec 080) + +**Input**: Design documents from `/specs/080-workspace-managed-tenant-admin/` (`plan.md`, `spec.md`, `contracts/routes.md`, `research.md`, `data-model.md`, `quickstart.md`) + +**Non-negotiables (repo rules)** +- Filament v5 + Livewire v4.0+ only. +- Laravel 11+: Filament panel providers are registered in `bootstrap/providers.php`. +- RBAC-UX semantics: non-member/non-entitled → 404; member missing capability on mutation → 403. +- Removed tenant-scoped management routes must not be registered (404, no redirects). +- Tests are REQUIRED (Pest). + +## Phase 1: Setup (Panel Scaffolding) + +**Purpose**: Introduce the tenant operations panel without changing behavior yet. + +- [X] T001 Create tenant operations panel provider in app/Providers/Filament/TenantPanelProvider.php +- [X] T002 Register new provider in bootstrap/providers.php (add App\Providers\Filament\TenantPanelProvider::class) + +**Checkpoint**: App boots with both panels registered. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the manage vs operate separation mechanisms (routing + middleware + discovery boundaries). + +- [X] T003 Refactor workspace panel to be tenantless in app/Providers/Filament/AdminPanelProvider.php (remove ->tenant(...), ->tenantRoutePrefix('t'), tenant menu/searchable menu) +- [X] T004 Update workspace panel middleware stack in app/Providers/Filament/AdminPanelProvider.php (remove ensure-filament-tenant-selected and App\Support\Middleware\DenyNonMemberTenantAccess for workspace panel) +- [X] T005 Configure tenant panel tenancy + middleware in app/Providers/Filament/TenantPanelProvider.php (enable ->tenant(App\Models\Tenant::class, slugAttribute: 'external_id'), enforce canonical path `/admin/t/{tenant}/...`, and add ensure-filament-tenant-selected + DenyNonMemberTenantAccess) +- [X] T006 Constrain resource/page discovery so management artifacts do not get registered in tenant panel in app/Providers/Filament/TenantPanelProvider.php (use dedicated discovery roots like app/Filament/Tenant/**) +- [X] T007 [P] Add a dedicated base feature test file tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (placeholder tests + factories usage notes) +- [X] T030 [P] [FOUNDATION] Add route-shape regression tests in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php asserting tenant routes resolve as `/admin/t/{tenant}` and `/admin/t/{tenant}/diagnostics` and never `/admin/t/t/{tenant}/...` + +**Checkpoint**: Workspace panel does not require tenant context; tenant panel is isolated by discovery roots. + +--- + +## Phase 3: User Story 1 — Manage tenants from workspace scope (Priority: P1) 🎯 MVP + +**Goal**: Tenants management is canonical under `/admin/tenants*` without needing tenant scope. + +**Independent Test**: As a workspace member, `GET /admin/tenants` returns 200; non-member returns 404. + +### Tests (Pest) — US1 + +- [X] T008 [P] [US1] Add access tests for workspace-managed tenant list in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (member 200, non-member 404) +- [X] T009 [P] [US1] Add access tests for workspace-managed tenant view route in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (member 200, non-member 404) +- [X] T031 [P] [US1] Add access tests for workspace-managed memberships route in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (`/admin/tenants/{tenant}/memberships`, member 200, non-member 404) + +### Implementation — US1 + +- [X] T010 [US1] Move/rename management routes to match /admin/tenants* in app/Filament/Resources/TenantResource.php (ensure resource slug becomes tenants under workspace panel) +- [X] T011 [US1] Ensure TenantResource is registered only in workspace panel (update app/Providers/Filament/AdminPanelProvider.php registration/discovery strategy) +- [X] T012 [US1] Ensure tenant route parameter identity uses Tenant.external_id in app/Models/Tenant.php (route key name) OR in TenantResource route binding configuration +- [X] T032 [US1] Ensure memberships management surface exists only under workspace scope in app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php and app/Filament/Resources/TenantResource.php (`/admin/tenants/{tenant}/memberships`) + +**Checkpoint**: `/admin/tenants` works in the workspace panel. + +--- + +## Phase 4: User Story 2 — Operate inside tenant scope only when entitled (Priority: P2) + +**Goal**: Operational pages remain under `/admin/t/{tenant}/*` and return 404 when user is not entitled. + +**Independent Test**: Entitled user can access `GET /admin/t/{tenant}` and `GET /admin/t/{tenant}/diagnostics`; non-entitled user gets 404. + +### Tests (Pest) — US2 + +- [X] T013 [P] [US2] Add tenant entitlement tests for concrete operational routes in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (`/admin/t/{tenant}` and `/admin/t/{tenant}/diagnostics`: entitled 200, non-entitled 404) + +### Implementation — US2 + +- [X] T014 [US2] Register/relocate tenant operational dashboard page into tenant panel in app/Filament/Pages/TenantDashboard.php and app/Providers/Filament/TenantPanelProvider.php +- [X] T015 [US2] Ensure tenant selection redirects into tenant panel routes in app/Filament/Pages/ChooseTenant.php (redirect should target /admin/t/{tenant}/...) + +**Checkpoint**: The contracted operational routes are reachable only when entitled. + +--- + +## Phase 5: User Story 3 — Remove tenant-scoped management CRUD routes (Priority: P3) + +**Goal**: Tenant-scoped management URLs do not resolve because resources/pages are not registered in tenant panel. + +**Independent Test**: Requests to removed routes return 404 (route does not exist). + +### Tests (Pest) — US3 + +- [X] T016 [P] [US3] Add 404 regression tests for removed routes in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (e.g. /admin/t/{tenant}/provider-connections, /admin/t/{tenant}/required-permissions) +- [X] T033 [P] [US3] Add tenant navigation regression test in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php ensuring tenant panel sidebar does not expose tenant-management entries (Tenants, Provider Connections, Memberships) + +### Implementation — US3 + +- [X] T017 [US3] Ensure management resources are not discovered/registered in tenant panel (verify TenantResource + ProviderConnectionResource not in tenant discovery roots in app/Providers/Filament/TenantPanelProvider.php) +- [X] T018 [US3] Ensure required permissions page is not registered in tenant panel in app/Providers/Filament/TenantPanelProvider.php (and/or relocate page class under workspace-managed location) +- [X] T034 [US3] Ensure tenant panel navigation is operate-only by construction in app/Providers/Filament/TenantPanelProvider.php (no TenantResource/ProviderConnectionResource/TenantMemberships registration) + +**Checkpoint**: Removed tenant-scoped management paths return 404 (no redirects). + +--- + +## Phase 6: User Story 4 — Management actions enforce capability semantics (Priority: P2) + +**Goal**: Workspace members can view management pages; mutations are forbidden (403) unless capability is present. + +**Independent Test**: A representative management mutation returns 403 for a workspace member missing capability. + +### Tests (Pest) — US4 + +- [X] T019 [P] [US4] Add mutation authorization test asserting 403 (member missing capability) in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php +- [X] T035 [P] [US4] Add audit assertion test for tenant membership mutation in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (writes redacted AuditLog with stable action ID) + +### Implementation — US4 + +- [X] T020 [US4] Audit management mutations in app/Services/Intune/AuditLogger.php (or existing audit service) for provider connection + membership changes with stable action IDs +- [X] T021 [US4] Ensure destructive-like actions use ->requiresConfirmation() in app/Filament/Resources/TenantResource.php and app/Filament/Resources/ProviderConnectionResource.php + +**Checkpoint**: 403 vs 404 semantics match spec for mutations. + +--- + +## Phase 7: User Story 5 — Global search isolation (Priority: P2) + +**Goal**: Tenant management entities are searchable only in workspace panel; tenant panel global search does not expose them. + +**Independent Test**: Workspace panel global search can resolve TenantResource results; tenant panel global search does not include TenantResource/ProviderConnectionResource. + +### Tests (Pest) — US5 + +- [X] T022 [P] [US5] Add global search scoping tests in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (workspace panel finds tenant; tenant panel does not expose management resources) + +### Implementation — US5 + +- [X] T023 [US5] Ensure TenantResource has a View/Edit page for global search compliance in app/Filament/Resources/TenantResource/Pages/* +- [X] T024 [US5] Ensure tenant panel does not register tenant-management resources/pages (verify discovery roots + resource registration in app/Providers/Filament/TenantPanelProvider.php) + +**Checkpoint**: Global search results do not leak across scopes. + +--- + +## Phase 8: Polish & Cross-Cutting + +**Purpose**: Link rewiring, consistency, formatting, and minimal regression validation. + +- [X] T025 [P] Rewire internal CTAs from tenant-scoped management URLs to workspace-managed routes in app/Filament/Pages/Workspaces/ManagedTenantsLanding.php and app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +- [X] T026 [P] Update required permissions viewer to be workspace-managed + DB-only in app/Filament/Pages/TenantRequiredPermissions.php (accept tenant via route param, avoid Tenant::current()) +- [X] T027 [P] Update provider connections to support workspace-managed per-tenant routes in app/Filament/Resources/ProviderConnectionResource.php (query should use route tenant param when not in tenancy) +- [X] T038 [P] Add Provider Connections CTA on tenant view page in app/Filament/Resources/TenantResource/Pages/ViewTenant.php (links to /admin/tenants/{tenant}/provider-connections) +- [X] T036 [P] Add workspace navigation regression test in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php to assert Tenants + Monitoring entries are present in workspace panel after panel split +- [X] T037 [P] Extend RBAC regression coverage for panel boundaries in tests/Feature/TenantRBAC/* (deny-as-not-found across workspace/tenant routing boundaries) +- [X] T028 Run formatting on touched files with vendor/bin/sail bin pint --dirty (formats app/** and tests/**) +- [X] T029 Run focused tests with vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php +- [X] T039 Run formatting on touched files again (post-T038) with vendor/bin/sail bin pint --dirty +- [X] T040 Re-run focused spec tests (post-T038) with vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php + +--- + +## Dependencies & Execution Order + +### User Story Dependency Graph + +- Setup (Phase 1) → Foundational (Phase 2) → US1 (MVP) +- US2 depends on Phase 2 +- US3 depends on Phase 2 (route removal works once panel discovery boundaries are in place) +- US4 depends on US1 (needs workspace management routes/actions) +- US5 depends on Phase 2 + US1 (workspace management resources must exist) +- Polish tasks depend on US1–US5 completion for stable regression assertions (T036, T037). + +### Parallel Opportunities + +- After Phase 2 completes: + - US1 tests (T008–T009, T031) can be written in parallel with US1 implementation (T010–T012, T032). + - US2 (T013–T015) can proceed in parallel with US1. + - US3 tests (T016, T033) can be added early as regression guards. + +--- + +## Parallel Example: US1 + +- Write tests: T008 + T009 in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php +- Implement routes: T010 in app/Filament/Resources/TenantResource.php + +--- + +## Implementation Strategy + +### MVP Scope + +- Complete Phase 1 + Phase 2 + US1 (T001–T012) +- Validate with T029 and manual checks from quickstart.md + +### Incremental Delivery + +- Add US2 + US3 next (routing guarantees + entitlement semantics) +- Then US4 (mutation semantics + audit) and US5 (global search isolation) diff --git a/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php b/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php new file mode 100644 index 0000000..46d69d4 --- /dev/null +++ b/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php @@ -0,0 +1,282 @@ +actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/tenants') + ->assertOk(); +}); + +it('returns 404 for non-members on the workspace-managed tenants index', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/tenants') + ->assertNotFound(); +}); + +it('allows workspace members to open the workspace-managed tenant view route', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}") + ->assertOk(); +}); + +it('exposes a provider connections link from the workspace-managed tenant view page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}") + ->assertOk() + ->assertSee("/admin/tenants/{$tenant->external_id}/provider-connections", false); +}); + +it('returns 404 for non-members on the workspace-managed tenant view route', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}") + ->assertNotFound(); +}); + +it('exposes memberships management under workspace scope', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}/memberships") + ->assertOk(); +}); + +it('requires tenant entitlement for the contracted tenant operational routes', function (): void { + $workspace = Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'external_id' => '11111111-1111-1111-1111-111111111111', + 'tenant_id' => '11111111-1111-1111-1111-111111111111', + ]); + + [$entitledUser] = createUserWithTenant($tenant, role: 'readonly'); + + $nonEntitledUser = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $nonEntitledUser->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($entitledUser) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get("/admin/t/{$tenant->external_id}") + ->assertOk(); + + $this->actingAs($entitledUser) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get("/admin/t/{$tenant->external_id}/diagnostics") + ->assertOk(); + + $this->actingAs($nonEntitledUser) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get("/admin/t/{$tenant->external_id}") + ->assertNotFound(); + + $this->actingAs($nonEntitledUser) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get("/admin/t/{$tenant->external_id}/diagnostics") + ->assertNotFound(); +}); + +it('keeps tenant panel route shape canonical and rejects duplicated /t prefixes', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/t/{$tenant->external_id}/diagnostics") + ->assertOk(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/t/t/{$tenant->external_id}/diagnostics") + ->assertNotFound(); +}); + +it('removes tenant-scoped management routes', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/t/{$tenant->external_id}/provider-connections") + ->assertNotFound(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/t/{$tenant->external_id}/required-permissions") + ->assertNotFound(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/t/{$tenant->external_id}/memberships") + ->assertNotFound(); +}); + +it('serves provider connection management under workspace-managed tenant routes only', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}/provider-connections") + ->assertOk(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}/provider-connections/{$connection->getKey()}/edit") + ->assertOk(); +}); + +it('returns 403 for workspace members missing mutation capability on provider connections', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}/provider-connections") + ->assertOk(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}/provider-connections/create") + ->assertForbidden(); +}); + +it('writes canonical membership audit entries for membership mutations', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + $member = User::factory()->create(); + + /** @var TenantMembershipManager $manager */ + $manager = app(TenantMembershipManager::class); + + $membership = $manager->addMember( + tenant: $tenant, + actor: $owner, + member: $member, + role: 'readonly', + source: 'manual', + ); + + $manager->changeRole( + tenant: $tenant, + actor: $owner, + membership: $membership, + newRole: 'operator', + ); + + $manager->removeMember( + tenant: $tenant, + actor: $owner, + membership: $membership, + ); + + $actions = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereIn('action', [ + AuditActionId::TenantMembershipAdd->value, + AuditActionId::TenantMembershipRoleChange->value, + AuditActionId::TenantMembershipRemove->value, + ]) + ->pluck('action') + ->all(); + + expect($actions)->toContain(AuditActionId::TenantMembershipAdd->value); + expect($actions)->toContain(AuditActionId::TenantMembershipRoleChange->value); + expect($actions)->toContain(AuditActionId::TenantMembershipRemove->value); +}); + +it('keeps workspace navigation entries after panel split', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/tenants') + ->assertOk() + ->assertSee('Tenants') + ->assertSee('Operations') + ->assertSee('Alerts') + ->assertSee('Audit Log'); +}); + +it('does not expose tenant-management resources in tenant panel registration or navigation URLs', function (): void { + $tenantPanelResources = Filament::getPanel('tenant')->getResources(); + + expect($tenantPanelResources)->not->toContain(TenantResource::class); + expect($tenantPanelResources)->not->toContain(ProviderConnectionResource::class); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/t/{$tenant->external_id}") + ->assertOk() + ->assertDontSee("/admin/t/{$tenant->external_id}/provider-connections", false) + ->assertDontSee("/admin/t/{$tenant->external_id}/tenants", false); +}); + +it('keeps global search scoped to workspace-managed tenant resources only', function (): void { + [$workspaceUser, $tenant] = createUserWithTenant(role: 'owner'); + + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + + $this->actingAs($workspaceUser); + + $results = TenantResource::getGlobalSearchResults((string) $tenant->name); + + expect($results->count())->toBeGreaterThan(0); + + $nonMember = User::factory()->create(); + + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + + $this->actingAs($nonMember); + + $nonMemberResults = TenantResource::getGlobalSearchResults((string) $tenant->name); + + expect($nonMemberResults)->toHaveCount(0); +}); diff --git a/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php b/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php index 741fe04..537abe2 100644 --- a/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php +++ b/tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php @@ -2,6 +2,7 @@ use App\Models\Tenant; use App\Models\User; +use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -22,3 +23,21 @@ ->get("/admin/t/{$tenant->external_id}") ->assertSuccessful(); }); + +it('enforces panel boundary semantics between workspace routes and tenant routes', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $otherTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'boundary-tenant-b', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/tenants/{$tenant->external_id}") + ->assertSuccessful(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/t/{$otherTenant->external_id}") + ->assertNotFound(); +}); diff --git a/tests/Unit/RequiredPermissionsLinksTest.php b/tests/Unit/RequiredPermissionsLinksTest.php index 969ab76..f7ecf66 100644 --- a/tests/Unit/RequiredPermissionsLinksTest.php +++ b/tests/Unit/RequiredPermissionsLinksTest.php @@ -9,7 +9,7 @@ ]); expect(RequiredPermissionsLinks::requiredPermissions($tenant)) - ->toBe('/admin/t/tenant-123/required-permissions'); + ->toBe('/admin/tenants/tenant-123/required-permissions'); }); it('builds a tenant-scoped required permissions link with filters', function (): void { @@ -22,5 +22,5 @@ 'type' => 'application', ]); - expect($url)->toBe('/admin/t/tenant+123/required-permissions?status=all&type=application'); + expect($url)->toBe('/admin/tenants/tenant+123/required-permissions?status=all&type=application'); });