diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 176f98f..756a10f 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -14,6 +14,8 @@ ## Active Technologies - PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish) - PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting) - PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting) +- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4 (068-workspaces-v2) +- PostgreSQL (via Sail) (068-workspaces-v2) - PHP 8.4.15 (feat/005-bulk-operations) @@ -33,9 +35,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 068-workspaces-v2: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4 - 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 - 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 -- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 14ca699..c1f6874 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -50,7 +50,8 @@ ### Tenant Isolation is Non-negotiable ### RBAC & UI Enforcement Standards (RBAC-UX) RBAC Context — Planes, Roles, and Auditability -- The platform MUST maintain two strictly separated authorization planes: +- The platform MUST maintain strictly separated authorization planes: + - Workspace plane (`/admin/w/{workspace}`): authenticated Entra users (`users`), authorization is workspace-scoped. - Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped. - Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped. - Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration. @@ -69,11 +70,11 @@ ### RBAC & UI Enforcement Standards (RBAC-UX) - Any missing server-side authorization is a P0 security bug. RBAC-UX-002 — Deny-as-not-found for non-members -- Tenant membership (and plane membership) is an isolation boundary. -- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST - respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources. -- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all - action endpoints (Livewire calls included). +- Workspace membership and tenant membership (and plane membership) are isolation boundaries. +- If the current actor is not a member of the current workspace or tenant (or otherwise not entitled to the relevant scope), the system MUST + respond as 404 (deny-as-not-found) for scope-scoped routes/actions/resources. +- This applies to Filament resources/pages under workspace routing (`/admin/w/{workspace}/...`) and tenant routing (`/admin/t/{tenant}/...`), + Global Search results, and all action endpoints (Livewire calls included). RBAC-UX-003 — Capability denial is 403 (after membership is established) - Within an established tenant scope, missing permissions are authorization failures. @@ -174,4 +175,4 @@ ### Versioning Policy (SemVer) - **MINOR**: new principle/section or materially expanded guidance. - **MAJOR**: removing/redefining principles in a backward-incompatible way. -**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28 +**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-31 diff --git a/app/Filament/Concerns/ScopesGlobalSearchToWorkspace.php b/app/Filament/Concerns/ScopesGlobalSearchToWorkspace.php new file mode 100644 index 0000000..80bf491 --- /dev/null +++ b/app/Filament/Concerns/ScopesGlobalSearchToWorkspace.php @@ -0,0 +1,53 @@ +hasTenancy()) { + $query->withoutGlobalScope($panel->getTenancyScopeName()); + } + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return $query->whereRaw('1 = 0'); + } + + /** @var WorkspaceContext $context */ + $context = app(WorkspaceContext::class); + + $workspace = $context->currentWorkspace(); + + if (! $workspace instanceof Workspace) { + return $query->whereRaw('1 = 0'); + } + + if (! $context->isMember($user, $workspace)) { + return $query->whereRaw('1 = 0'); + } + + return $query->whereBelongsTo($workspace, static::$globalSearchWorkspaceRelationship); + } +} diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php new file mode 100644 index 0000000..e5ac8c5 --- /dev/null +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -0,0 +1,137 @@ + + */ + protected function getHeaderActions(): array + { + return [ + Action::make('createWorkspace') + ->label('Create workspace') + ->modalHeading('Create workspace') + ->form([ + TextInput::make('name') + ->required() + ->maxLength(255), + TextInput::make('slug') + ->helperText('Optional. Used in URLs if set.') + ->maxLength(255) + ->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug']) + ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) + ->dehydrated(fn ($state) => filled($state)), + ]) + ->action(fn (array $data) => $this->createWorkspace($data)), + ]; + } + + /** + * @return Collection + */ + public function getWorkspaces(): Collection + { + $user = auth()->user(); + + if (! $user instanceof User) { + return Workspace::query()->whereRaw('1 = 0')->get(); + } + + return Workspace::query() + ->whereIn('id', function ($query) use ($user): void { + $query->from('workspace_memberships') + ->select('workspace_id') + ->where('user_id', $user->getKey()); + }) + ->whereNull('archived_at') + ->orderBy('name') + ->get(); + } + + public function selectWorkspace(int $workspaceId): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + if (! empty($workspace->archived_at)) { + abort(404); + } + + $context = app(WorkspaceContext::class); + + if (! $context->isMember($user, $workspace)) { + abort(404); + } + + $context->setCurrentWorkspace($workspace, $user, request()); + + $this->redirect('/admin/tenants'); + } + + /** + * @param array{name: string, slug?: string|null} $data + */ + public function createWorkspace(array $data): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspace = Workspace::query()->create([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? null, + ]); + + WorkspaceMembership::query()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request()); + + Notification::make() + ->title('Workspace created') + ->success() + ->send(); + + $this->redirect('/admin/tenants'); + } +} diff --git a/app/Filament/Pages/NoAccess.php b/app/Filament/Pages/NoAccess.php index 07574bf..0d1369d 100644 --- a/app/Filament/Pages/NoAccess.php +++ b/app/Filament/Pages/NoAccess.php @@ -4,6 +4,13 @@ namespace App\Filament\Pages; +use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; +use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Action; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; use Filament\Pages\Page; class NoAccess extends Page @@ -19,4 +26,60 @@ class NoAccess extends Page protected static ?string $title = 'No access'; protected string $view = 'filament.pages.no-access'; + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + Action::make('createWorkspace') + ->label('Create workspace') + ->modalHeading('Create workspace') + ->form([ + TextInput::make('name') + ->required() + ->maxLength(255), + TextInput::make('slug') + ->helperText('Optional. Used in URLs if set.') + ->maxLength(255) + ->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug']) + ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) + ->dehydrated(fn ($state) => filled($state)), + ]) + ->action(fn (array $data) => $this->createWorkspace($data)), + ]; + } + + /** + * @param array{name: string, slug?: string|null} $data + */ + public function createWorkspace(array $data): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspace = Workspace::query()->create([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? null, + ]); + + WorkspaceMembership::query()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request()); + + Notification::make() + ->title('Workspace created') + ->success() + ->send(); + + $this->redirect('/admin/tenants'); + } } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index d52f5a6..5d4669d 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Concerns\ScopesGlobalSearchToWorkspace; use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\RelationManagers; use App\Http\Controllers\RbacDelegatedAuthController; @@ -9,8 +10,10 @@ use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; +use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; @@ -30,6 +33,7 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; +use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -53,6 +57,8 @@ class TenantResource extends Resource { + use ScopesGlobalSearchToWorkspace; + // ... [Properties Omitted for Brevity] ... protected static ?string $model = Tenant::class; @@ -70,7 +76,7 @@ public static function canCreate(): bool return false; } - return static::userCanManageAnyTenant($user); + return static::userCanManageTenantsInCurrentWorkspace($user); } public static function canEdit(Model $record): bool @@ -81,11 +87,12 @@ public static function canEdit(Model $record): bool return false; } - /** @var CapabilityResolver $resolver */ - $resolver = app(CapabilityResolver::class); + $workspace = static::resolveCurrentWorkspaceFor($user); return $record instanceof Tenant - && $resolver->can($user, $record, Capabilities::TENANT_MANAGE); + && $workspace instanceof Workspace + && (int) $record->workspace_id === (int) $workspace->getKey() + && static::userCanManageTenantsInCurrentWorkspace($user); } public static function canDelete(Model $record): bool @@ -96,11 +103,12 @@ public static function canDelete(Model $record): bool return false; } - /** @var CapabilityResolver $resolver */ - $resolver = app(CapabilityResolver::class); + $workspace = static::resolveCurrentWorkspaceFor($user); return $record instanceof Tenant - && $resolver->can($user, $record, Capabilities::TENANT_DELETE); + && $workspace instanceof Workspace + && (int) $record->workspace_id === (int) $workspace->getKey() + && static::userCanDeleteTenantsInCurrentWorkspace($user); } public static function canDeleteAny(): bool @@ -111,21 +119,49 @@ public static function canDeleteAny(): bool return false; } - return static::userCanDeleteAnyTenant($user); + return static::userCanDeleteTenantsInCurrentWorkspace($user); } - private static function userCanManageAnyTenant(User $user): bool + private static function userCanDeleteTenantsInCurrentWorkspace(User $user): bool { - return $user->tenantMemberships() - ->pluck('role') - ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE)); + $workspace = static::resolveCurrentWorkspaceFor($user); + + if (! $workspace instanceof Workspace) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE); } - private static function userCanDeleteAnyTenant(User $user): bool + private static function userCanManageTenantsInCurrentWorkspace(User $user): bool { - return $user->tenantMemberships() - ->pluck('role') - ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE)); + $workspace = static::resolveCurrentWorkspaceFor($user); + + if (! $workspace instanceof Workspace) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + } + + private static function resolveCurrentWorkspaceFor(User $user): ?Workspace + { + /** @var WorkspaceContext $context */ + $context = app(WorkspaceContext::class); + + $workspace = $context->resolveInitialWorkspaceFor($user); + + if (! $workspace instanceof Workspace) { + return null; + } + + return $context->isMember($user, $workspace) ? $workspace : null; } public static function form(Schema $schema): Schema @@ -172,20 +208,21 @@ public static function form(Schema $schema): Schema public static function getEloquentQuery(): Builder { - // ... [Query Omitted - No Change] ... $user = auth()->user(); if (! $user instanceof User) { return parent::getEloquentQuery()->whereRaw('1 = 0'); } - $tenantIds = $user->tenants() - ->withTrashed() - ->pluck('tenants.id'); + $workspace = static::resolveCurrentWorkspaceFor($user); + + if (! $workspace instanceof Workspace) { + return parent::getEloquentQuery()->whereRaw('1 = 0'); + } return parent::getEloquentQuery() ->withTrashed() - ->whereIn('id', $tenantIds) + ->where('workspace_id', (int) $workspace->getKey()) ->withCount('policies') ->withMax('policies as last_policy_sync_at', 'last_synced_at'); } @@ -873,8 +910,12 @@ public static function rbacAction(): Actions\Action ->noSearchResultsMessage('No security groups found') ->loadingMessage('Searching groups...'), ]) - ->visible(fn (Tenant $record): bool => $record->isActive()) - ->disabled(function (Tenant $record): bool { + ->visible(fn (?Tenant $record): bool => (bool) $record?->isActive()) + ->disabled(function (?Tenant $record): bool { + if ($record === null) { + return true; + } + $user = auth()->user(); if (! $user instanceof User) { diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php index 6d3bd89..1dccdee 100644 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -4,12 +4,27 @@ use App\Filament\Resources\TenantResource; use App\Models\User; +use App\Models\Workspace; +use App\Support\Workspaces\WorkspaceContext; use Filament\Resources\Pages\CreateRecord; class CreateTenant extends CreateRecord { protected static string $resource = TenantResource::class; + protected function mutateFormDataBeforeCreate(array $data): array + { + $workspace = app(WorkspaceContext::class)->currentWorkspace(request()); + + if (! $workspace instanceof Workspace) { + abort(403); + } + + $data['workspace_id'] = (int) $workspace->getKey(); + + return $data; + } + protected function afterCreate(): void { $user = auth()->user(); diff --git a/app/Filament/Resources/TenantResource/Pages/ListTenants.php b/app/Filament/Resources/TenantResource/Pages/ListTenants.php index 48fcad2..512af7d 100644 --- a/app/Filament/Resources/TenantResource/Pages/ListTenants.php +++ b/app/Filament/Resources/TenantResource/Pages/ListTenants.php @@ -14,8 +14,9 @@ protected function getHeaderActions(): array { return [ Actions\CreateAction::make() + ->label('Add managed tenant') ->disabled(fn (): bool => ! TenantResource::canCreate()) - ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'), + ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to add managed tenants.'), ]; } } diff --git a/app/Filament/Resources/TenantResource/Pages/OnboardingManagedTenant.php b/app/Filament/Resources/TenantResource/Pages/OnboardingManagedTenant.php new file mode 100644 index 0000000..d7a6e31 --- /dev/null +++ b/app/Filament/Resources/TenantResource/Pages/OnboardingManagedTenant.php @@ -0,0 +1,23 @@ +redirect('/admin/tenants/create'); + } +} diff --git a/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php b/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php new file mode 100644 index 0000000..8829038 --- /dev/null +++ b/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php @@ -0,0 +1,11 @@ + $this->getOwnerRecord(); + + return $table + ->modifyQueryUsing(fn (Builder $query) => $query->with('user')) + ->columns([ + Tables\Columns\TextColumn::make('user.name') + ->label(__('User')) + ->searchable(), + Tables\Columns\TextColumn::make('user.email') + ->label(__('Email')) + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('role') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('created_at')->since(), + ]) + ->filters([ + // + ]) + ->headerActions([ + UiEnforcement::forTableAction( + Action::make('add_member') + ->label(__('Add member')) + ->icon('heroicon-o-plus') + ->form([ + Forms\Components\Select::make('user_id') + ->label(__('User')) + ->required() + ->searchable() + ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + 'owner' => __('Owner'), + 'manager' => __('Manager'), + 'operator' => __('Operator'), + 'readonly' => __('Readonly'), + ]), + ]) + ->action(function (array $data, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + $member = User::query()->find((int) $data['user_id']); + if (! $member) { + Notification::make()->title(__('User not found'))->danger()->send(); + + return; + } + + try { + $manager->addMember( + workspace: $workspace, + actor: $actor, + member: $member, + role: (string) $data['role'], + source: 'manual', + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to add member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Member added'))->success()->send(); + $this->resetTable(); + }), + $workspaceRecord, + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->apply(), + ]) + ->recordActions([ + UiEnforcement::forTableAction( + Action::make('change_role') + ->label(__('Change role')) + ->icon('heroicon-o-pencil') + ->requiresConfirmation() + ->form([ + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + 'owner' => __('Owner'), + 'manager' => __('Manager'), + 'operator' => __('Operator'), + 'readonly' => __('Readonly'), + ]), + ]) + ->action(function (WorkspaceMembership $record, array $data, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + try { + $manager->changeRole( + workspace: $workspace, + actor: $actor, + membership: $record, + newRole: (string) $data['role'], + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to change role')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Role updated'))->success()->send(); + $this->resetTable(); + }), + $workspaceRecord, + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->apply(), + + UiEnforcement::forTableAction( + Action::make('remove') + ->label(__('Remove')) + ->color('danger') + ->icon('heroicon-o-x-mark') + ->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + try { + $manager->removeMember( + workspace: $workspace, + actor: $actor, + membership: $record, + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to remove member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Member removed'))->success()->send(); + $this->resetTable(); + }), + $workspaceRecord, + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->destructive() + ->apply(), + ]) + ->toolbarActions([]) + ->bulkActions([]); + } +} diff --git a/app/Filament/Resources/Workspaces/Schemas/WorkspaceForm.php b/app/Filament/Resources/Workspaces/Schemas/WorkspaceForm.php new file mode 100644 index 0000000..531e737 --- /dev/null +++ b/app/Filament/Resources/Workspaces/Schemas/WorkspaceForm.php @@ -0,0 +1,19 @@ +components([ + TextInput::make('name') + ->required(), + TextInput::make('slug'), + ]); + } +} diff --git a/app/Filament/Resources/Workspaces/Schemas/WorkspaceInfolist.php b/app/Filament/Resources/Workspaces/Schemas/WorkspaceInfolist.php new file mode 100644 index 0000000..d7c09d0 --- /dev/null +++ b/app/Filament/Resources/Workspaces/Schemas/WorkspaceInfolist.php @@ -0,0 +1,25 @@ +components([ + TextEntry::make('name'), + TextEntry::make('slug') + ->placeholder('-'), + TextEntry::make('created_at') + ->dateTime() + ->placeholder('-'), + TextEntry::make('updated_at') + ->dateTime() + ->placeholder('-'), + ]); + } +} diff --git a/app/Filament/Resources/Workspaces/Tables/WorkspacesTable.php b/app/Filament/Resources/Workspaces/Tables/WorkspacesTable.php new file mode 100644 index 0000000..5ad0c7c --- /dev/null +++ b/app/Filament/Resources/Workspaces/Tables/WorkspacesTable.php @@ -0,0 +1,88 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + UiEnforcement::forAction( + Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box') + ->visible(fn (Workspace $record): bool => empty($record->archived_at)) + ->action(function (Workspace $record): void { + $record->forceFill(['archived_at' => now()])->save(); + + Notification::make() + ->title('Workspace archived') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::WORKSPACE_MANAGE) + ->destructive() + ->tooltip('You do not have permission to archive this workspace.') + ->apply(), + UiEnforcement::forAction( + Action::make('unarchive') + ->label('Unarchive') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->visible(fn (Workspace $record): bool => ! empty($record->archived_at)) + ->action(function (Workspace $record): void { + $record->forceFill(['archived_at' => null])->save(); + + Notification::make() + ->title('Workspace unarchived') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::WORKSPACE_MANAGE) + ->tooltip('You do not have permission to unarchive this workspace.') + ->apply(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Workspaces/WorkspaceResource.php b/app/Filament/Resources/Workspaces/WorkspaceResource.php new file mode 100644 index 0000000..2ab2969 --- /dev/null +++ b/app/Filament/Resources/Workspaces/WorkspaceResource.php @@ -0,0 +1,79 @@ +user(); + + if (! $user instanceof User) { + return parent::getEloquentQuery()->whereRaw('1 = 0'); + } + + $workspaceIds = $user->newQuery() + ->join('workspace_memberships', 'users.id', '=', 'workspace_memberships.user_id') + ->where('users.id', $user->getKey()) + ->pluck('workspace_memberships.workspace_id'); + + return parent::getEloquentQuery()->whereIn('id', $workspaceIds); + } + + public static function getRelations(): array + { + return [ + MembershipsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListWorkspaces::route('/'), + 'create' => CreateWorkspace::route('/create'), + 'view' => ViewWorkspace::route('/{record}'), + 'edit' => EditWorkspace::route('/{record}/edit'), + ]; + } +} diff --git a/app/Http/Middleware/EnsureWorkspaceMember.php b/app/Http/Middleware/EnsureWorkspaceMember.php new file mode 100644 index 0000000..e79dd6c --- /dev/null +++ b/app/Http/Middleware/EnsureWorkspaceMember.php @@ -0,0 +1,51 @@ +user(); + + if (! $user instanceof User) { + return $next($request); + } + + $workspaceParam = $request->route()?->parameter('workspace'); + + $workspace = $workspaceParam instanceof Workspace + ? $workspaceParam + : (is_scalar($workspaceParam) + ? app(WorkspaceResolver::class)->resolve((string) $workspaceParam) + : null); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + /** @var WorkspaceContext $context */ + $context = app(WorkspaceContext::class); + + if (! $context->isMember($user, $workspace)) { + abort(404); + } + + $context->setCurrentWorkspace($workspace, $user, $request); + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php new file mode 100644 index 0000000..b904ff7 --- /dev/null +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -0,0 +1,67 @@ +route()?->getName(); + + if (is_string($routeName) && str_contains($routeName, '.auth.')) { + return $next($request); + } + + $path = '/'.ltrim($request->path(), '/'); + + if (str_starts_with($path, '/admin/t/')) { + return $next($request); + } + + if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) { + return $next($request); + } + + $user = $request->user(); + + if (! $user instanceof User) { + return $next($request); + } + + /** @var WorkspaceContext $context */ + $context = app(WorkspaceContext::class); + + $workspace = $context->resolveInitialWorkspaceFor($user, $request); + + if ($workspace !== null) { + return $next($request); + } + + $membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey()); + + $hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at') + ? $membershipQuery + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->exists() + : $membershipQuery->exists(); + + $target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access'; + + return new HttpResponse('', 302, ['Location' => $target]); + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php index a29a038..bc2f56d 100644 --- a/app/Models/AuditLog.php +++ b/app/Models/AuditLog.php @@ -21,4 +21,9 @@ public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } } diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index c1d98b6..0f6159c 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; @@ -170,6 +171,11 @@ public function memberships(): HasMany return $this->hasMany(TenantMembership::class); } + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + public function roleMappings(): HasMany { return $this->hasMany(TenantRoleMapping::class); diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php new file mode 100644 index 0000000..d351b3c --- /dev/null +++ b/app/Models/Workspace.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + protected $guarded = []; + + /** + * @return HasMany + */ + public function memberships(): HasMany + { + return $this->hasMany(WorkspaceMembership::class); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'workspace_memberships') + ->using(WorkspaceMembership::class) + ->withPivot(['id', 'role']) + ->withTimestamps(); + } + + /** + * @return HasMany + */ + public function tenants(): HasMany + { + return $this->hasMany(Tenant::class); + } +} diff --git a/app/Models/WorkspaceMembership.php b/app/Models/WorkspaceMembership.php new file mode 100644 index 0000000..d2f7413 --- /dev/null +++ b/app/Models/WorkspaceMembership.php @@ -0,0 +1,31 @@ + */ + use HasFactory; + + protected $guarded = []; + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Policies/WorkspaceMembershipPolicy.php b/app/Policies/WorkspaceMembershipPolicy.php new file mode 100644 index 0000000..df36eac --- /dev/null +++ b/app/Policies/WorkspaceMembershipPolicy.php @@ -0,0 +1,108 @@ +can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_VIEW); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, WorkspaceMembership $workspaceMembership): bool + { + if ($this->isLastOwner($workspaceMembership)) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, WorkspaceMembership $workspaceMembership): bool + { + if ($this->isLastOwner($workspaceMembership)) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, WorkspaceMembership $workspaceMembership): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, WorkspaceMembership $workspaceMembership): bool + { + return false; + } + + public function manageForWorkspace(User $user, Workspace $workspace): bool + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + } + + private function isLastOwner(WorkspaceMembership $membership): bool + { + if ($membership->role !== WorkspaceRole::Owner->value) { + return false; + } + + $ownerCount = WorkspaceMembership::query() + ->where('workspace_id', $membership->workspace_id) + ->where('role', WorkspaceRole::Owner->value) + ->count(); + + return $ownerCount <= 1; + } +} diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php new file mode 100644 index 0000000..0a40b4b --- /dev/null +++ b/app/Policies/WorkspacePolicy.php @@ -0,0 +1,74 @@ +where('user_id', $user->getKey()) + ->where('workspace_id', $workspace->getKey()) + ->exists(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Workspace $workspace): bool + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Workspace $workspace): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Workspace $workspace): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Workspace $workspace): bool + { + return false; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index d52e8f8..a0de6ce 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -6,8 +6,13 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use App\Policies\ProviderConnectionPolicy; +use App\Policies\WorkspaceMembershipPolicy; +use App\Policies\WorkspacePolicy; use App\Services\Auth\CapabilityResolver; +use App\Services\Auth\WorkspaceCapabilityResolver; use App\Support\Auth\Capabilities; use App\Support\Auth\PlatformCapabilities; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; @@ -17,21 +22,36 @@ class AuthServiceProvider extends ServiceProvider { protected $policies = [ ProviderConnection::class => ProviderConnectionPolicy::class, + Workspace::class => WorkspacePolicy::class, + WorkspaceMembership::class => WorkspaceMembershipPolicy::class, ]; public function boot(): void { $this->registerPolicies(); - $resolver = app(CapabilityResolver::class); + $tenantResolver = app(CapabilityResolver::class); + $workspaceResolver = app(WorkspaceCapabilityResolver::class); - $defineTenantCapability = function (string $capability) use ($resolver): void { - Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool { - return $resolver->can($user, $tenant, $capability); + $defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void { + Gate::define($capability, function (User $user, Workspace $workspace) use ($workspaceResolver, $capability): bool { + return $workspaceResolver->can($user, $workspace, $capability); + }); + }; + + $defineTenantCapability = function (string $capability) use ($tenantResolver): void { + Gate::define($capability, function (User $user, Tenant $tenant) use ($tenantResolver, $capability): bool { + return $tenantResolver->can($user, $tenant, $capability); }); }; foreach (Capabilities::all() as $capability) { + if (str_starts_with($capability, 'workspace.') || str_starts_with($capability, 'workspace_membership.')) { + $defineWorkspaceCapability($capability); + + continue; + } + $defineTenantCapability($capability); } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f4fae79..3dc22c5 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -4,11 +4,15 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Pages\ChooseTenant; +use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\NoAccess; use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\TenantDashboard; +use App\Models\User; +use App\Models\WorkspaceMembership; use App\Models\Tenant; use App\Support\Middleware\DenyNonMemberTenantAccess; +use Filament\Actions\Action; use Filament\Facades\Filament; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; @@ -23,6 +27,7 @@ use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; +use Illuminate\Support\Facades\Route; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; @@ -38,7 +43,17 @@ public function panel(Panel $panel): Panel ->login(Login::class) ->authenticatedRoutes(function (Panel $panel): void { ChooseTenant::registerRoutes($panel); + ChooseWorkspace::registerRoutes($panel); NoAccess::registerRoutes($panel); + + if ($panel->hasTenantRegistration()) { + $tenantRegistrationPage = $panel->getTenantRegistrationPage(); + + Route::get($tenantRegistrationPage::getRoutePath($panel), $tenantRegistrationPage) + ->middleware($tenantRegistrationPage::getRouteMiddleware($panel)) + ->withoutMiddleware($tenantRegistrationPage::getWithoutRouteMiddleware($panel)) + ->name('tenant.registration'); + } }) ->tenant(Tenant::class, slugAttribute: 'external_id') ->tenantRoutePrefix('t') @@ -70,6 +85,24 @@ public function panel(Panel $panel): Panel FilamentInfoWidget::class, ]) ->databaseNotifications() + ->userMenuItems([ + Action::make('switch-workspace') + ->label('Switch workspace') + ->icon('heroicon-o-squares-2x2') + ->url('/admin/choose-workspace') + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->count() > 1; + }) + ->sort(0), + ]) ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, @@ -85,6 +118,7 @@ public function panel(Panel $panel): Panel ]) ->authMiddleware([ Authenticate::class, + 'ensure-workspace-selected', ]); if (! app()->runningUnitTests()) { diff --git a/app/Services/Audit/WorkspaceAuditLogger.php b/app/Services/Audit/WorkspaceAuditLogger.php new file mode 100644 index 0000000..ac0f466 --- /dev/null +++ b/app/Services/Audit/WorkspaceAuditLogger.php @@ -0,0 +1,40 @@ + null, + 'workspace_id' => (int) $workspace->getKey(), + 'actor_id' => $actor?->getKey(), + 'actor_email' => $actor?->email, + 'actor_name' => $actor?->name, + 'action' => $action, + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + 'status' => $status, + 'metadata' => $metadata + $context, + 'recorded_at' => CarbonImmutable::now(), + ]); + } +} diff --git a/app/Services/Auth/WorkspaceCapabilityResolver.php b/app/Services/Auth/WorkspaceCapabilityResolver.php new file mode 100644 index 0000000..10f3c8c --- /dev/null +++ b/app/Services/Auth/WorkspaceCapabilityResolver.php @@ -0,0 +1,100 @@ +getMembership($user, $workspace); + + if ($membership === null) { + return null; + } + + return WorkspaceRole::tryFrom($membership['role']); + } + + public function can(User $user, Workspace $workspace, string $capability): bool + { + if (! Capabilities::isKnown($capability)) { + throw new \InvalidArgumentException("Unknown capability: {$capability}"); + } + + $role = $this->getRole($user, $workspace); + + if ($role === null) { + $this->logDenial($user, $workspace, $capability); + + return false; + } + + $allowed = WorkspaceRoleCapabilityMap::hasCapability($role, $capability); + + if (! $allowed) { + $this->logDenial($user, $workspace, $capability); + } + + return $allowed; + } + + public function isMember(User $user, Workspace $workspace): bool + { + return $this->getMembership($user, $workspace) !== null; + } + + public function clearCache(): void + { + $this->resolvedMemberships = []; + } + + private function logDenial(User $user, Workspace $workspace, string $capability): void + { + $key = implode(':', [(string) $user->getKey(), (string) $workspace->getKey(), $capability]); + + if (isset($this->loggedDenials[$key])) { + return; + } + + $this->loggedDenials[$key] = true; + + Log::warning('rbac.workspace.denied', [ + 'capability' => $capability, + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $user->getKey(), + ]); + } + + private function getMembership(User $user, Workspace $workspace): ?array + { + $cacheKey = "workspace_membership_{$user->id}_{$workspace->id}"; + + if (! isset($this->resolvedMemberships[$cacheKey])) { + $membership = WorkspaceMembership::query() + ->where('user_id', $user->id) + ->where('workspace_id', $workspace->id) + ->first(['role']); + + $this->resolvedMemberships[$cacheKey] = $membership?->toArray(); + } + + return $this->resolvedMemberships[$cacheKey]; + } +} diff --git a/app/Services/Auth/WorkspaceMembershipManager.php b/app/Services/Auth/WorkspaceMembershipManager.php new file mode 100644 index 0000000..ac9d4a1 --- /dev/null +++ b/app/Services/Auth/WorkspaceMembershipManager.php @@ -0,0 +1,274 @@ +assertValidRole($role); + $this->assertActorCanManage($actor, $workspace); + + return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership { + $existing = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('user_id', (int) $member->getKey()) + ->first(); + + if ($existing) { + if ($existing->role !== $role) { + $fromRole = (string) $existing->role; + + $existing->forceFill([ + 'role' => $role, + ])->save(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipRoleChange->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $member->getKey(), + 'from_role' => $fromRole, + 'to_role' => $role, + 'source' => $source, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } + + return $existing->refresh(); + } + + $membership = WorkspaceMembership::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $member->getKey(), + 'role' => $role, + ]); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipAdd->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $member->getKey(), + 'role' => $role, + 'source' => $source, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + + return $membership; + }); + } + + public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership + { + $this->assertValidRole($newRole); + $this->assertActorCanManage($actor, $workspace); + + try { + return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership { + $membership->refresh(); + + if ($membership->workspace_id !== (int) $workspace->getKey()) { + throw new DomainException('Membership belongs to a different workspace.'); + } + + $oldRole = (string) $membership->role; + + if ($oldRole === $newRole) { + return $membership; + } + + $this->guardLastOwnerDemotion($workspace, $membership, $newRole); + + $membership->forceFill([ + 'role' => $newRole, + ])->save(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipRoleChange->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'from_role' => $oldRole, + 'to_role' => $newRole, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + + return $membership->refresh(); + }); + } catch (DomainException $exception) { + if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'from_role' => (string) $membership->role, + 'attempted_to_role' => $newRole, + ], + ], + actor: $actor, + status: 'blocked', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } + + throw $exception; + } + } + + public function removeMember(Workspace $workspace, User $actor, WorkspaceMembership $membership): void + { + $this->assertActorCanManage($actor, $workspace); + + try { + DB::transaction(function () use ($workspace, $actor, $membership): void { + $membership->refresh(); + + if ($membership->workspace_id !== (int) $workspace->getKey()) { + throw new DomainException('Membership belongs to a different workspace.'); + } + + $this->guardLastOwnerRemoval($workspace, $membership); + + $memberUserId = (int) $membership->user_id; + $oldRole = (string) $membership->role; + + $membership->delete(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipRemove->value, + context: [ + 'metadata' => [ + 'member_user_id' => $memberUserId, + 'role' => $oldRole, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + }); + } catch (DomainException $exception) { + if ($exception->getMessage() === 'You cannot remove the last remaining owner.') { + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'role' => (string) $membership->role, + 'attempted_action' => 'remove', + ], + ], + actor: $actor, + status: 'blocked', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } + + throw $exception; + } + } + + private function assertActorCanManage(User $actor, Workspace $workspace): void + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) { + throw new DomainException('Forbidden.'); + } + } + + private function assertValidRole(string $role): void + { + $valid = array_map( + static fn (WorkspaceRole $workspaceRole): string => $workspaceRole->value, + WorkspaceRole::cases(), + ); + + if (! in_array($role, $valid, true)) { + throw new DomainException('Invalid role.'); + } + } + + private function guardLastOwnerDemotion(Workspace $workspace, WorkspaceMembership $membership, string $newRole): void + { + if ($membership->role !== WorkspaceRole::Owner->value) { + return; + } + + if ($newRole === WorkspaceRole::Owner->value) { + return; + } + + $owners = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('role', WorkspaceRole::Owner->value) + ->count(); + + if ($owners <= 1) { + throw new DomainException('You cannot demote the last remaining owner.'); + } + } + + private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership $membership): void + { + if ($membership->role !== WorkspaceRole::Owner->value) { + return; + } + + $owners = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('role', WorkspaceRole::Owner->value) + ->count(); + + if ($owners <= 1) { + throw new DomainException('You cannot remove the last remaining owner.'); + } + } +} diff --git a/app/Services/Auth/WorkspaceRoleCapabilityMap.php b/app/Services/Auth/WorkspaceRoleCapabilityMap.php new file mode 100644 index 0000000..9e78923 --- /dev/null +++ b/app/Services/Auth/WorkspaceRoleCapabilityMap.php @@ -0,0 +1,74 @@ +> + */ + private static array $roleCapabilities = [ + WorkspaceRole::Owner->value => [ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MANAGE, + Capabilities::WORKSPACE_ARCHIVE, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, + ], + + WorkspaceRole::Manager->value => [ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, + ], + + WorkspaceRole::Operator->value => [ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + ], + + WorkspaceRole::Readonly->value => [ + Capabilities::WORKSPACE_VIEW, + ], + ]; + + /** + * @return array + */ + public static function getCapabilities(WorkspaceRole|string $role): array + { + $roleValue = $role instanceof WorkspaceRole ? $role->value : $role; + + return self::$roleCapabilities[$roleValue] ?? []; + } + + /** + * @return array + */ + public static function rolesWithCapability(string $capability): array + { + $roles = []; + + foreach (self::$roleCapabilities as $role => $capabilities) { + if (in_array($capability, $capabilities, true)) { + $roles[] = $role; + } + } + + return $roles; + } + + public static function hasCapability(WorkspaceRole|string $role, string $capability): bool + { + return in_array($capability, self::getCapabilities($role), true); + } +} diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index e093b64..3e0d5e5 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -16,4 +16,9 @@ enum AuditActionId: string // Diagnostics / repair actions. case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged'; + + case WorkspaceMembershipAdd = 'workspace_membership.add'; + case WorkspaceMembershipRoleChange = 'workspace_membership.role_change'; + case WorkspaceMembershipRemove = 'workspace_membership.remove'; + case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked'; } diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index 47bcc50..06878cf 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -55,6 +55,18 @@ class Capabilities // Audit public const AUDIT_VIEW = 'audit.view'; + // Workspaces + public const WORKSPACE_VIEW = 'workspace.view'; + + public const WORKSPACE_MANAGE = 'workspace.manage'; + + public const WORKSPACE_ARCHIVE = 'workspace.archive'; + + // Workspace memberships + public const WORKSPACE_MEMBERSHIP_VIEW = 'workspace_membership.view'; + + public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage'; + /** * Get all capability constants * diff --git a/app/Support/Auth/WorkspaceRole.php b/app/Support/Auth/WorkspaceRole.php new file mode 100644 index 0000000..ef10970 --- /dev/null +++ b/app/Support/Auth/WorkspaceRole.php @@ -0,0 +1,11 @@ +user(); + $workspace = $this->resolveWorkspaceWithRecord($record); + + if ($workspace instanceof Workspace) { + if (! $user instanceof User) { + return new WorkspaceAccessContext( + user: null, + workspace: null, + isMember: false, + hasCapability: false, + ); + } + + $isMember = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('user_id', (int) $user->getKey()) + ->exists(); + + $hasCapability = true; + if ($this->capability !== null && $isMember) { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + $hasCapability = $resolver->can($user, $workspace, $this->capability); + } + + return new WorkspaceAccessContext( + user: $user, + workspace: $workspace, + isMember: $isMember, + hasCapability: $hasCapability, + ); + } + // For table actions, resolve the record and use it as tenant if it's a Tenant $tenant = $this->resolveTenantWithRecord($record); @@ -383,6 +418,33 @@ private function resolveContextWithRecord(?Model $record = null): TenantAccessCo ); } + private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace + { + if ($record instanceof Workspace) { + return $record; + } + + if ($record instanceof WorkspaceMembership) { + return $record->workspace; + } + + if ($this->record !== null) { + $resolved = $this->record instanceof Closure + ? ($this->record)() + : $this->record; + + if ($resolved instanceof Workspace) { + return $resolved; + } + + if ($resolved instanceof WorkspaceMembership) { + return $resolved->workspace; + } + } + + return null; + } + /** * Resolve the tenant for this action with an optional record. * diff --git a/app/Support/Rbac/WorkspaceAccessContext.php b/app/Support/Rbac/WorkspaceAccessContext.php new file mode 100644 index 0000000..38074f3 --- /dev/null +++ b/app/Support/Rbac/WorkspaceAccessContext.php @@ -0,0 +1,45 @@ +isMember; + } + + /** + * Members without capability should receive 403 (forbidden). + */ + public function shouldDenyAsForbidden(): bool + { + return $this->isMember && ! $this->hasCapability; + } + + /** + * User is authorized to perform the action. + */ + public function isAuthorized(): bool + { + return $this->isMember && $this->hasCapability; + } +} diff --git a/app/Support/Workspaces/WorkspaceContext.php b/app/Support/Workspaces/WorkspaceContext.php new file mode 100644 index 0000000..8bcb598 --- /dev/null +++ b/app/Support/Workspaces/WorkspaceContext.php @@ -0,0 +1,135 @@ +hasSession()) ? $request->session() : session(); + + $id = $session->get(self::SESSION_KEY); + + return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null); + } + + public function currentWorkspace(?Request $request = null): ?Workspace + { + $id = $this->currentWorkspaceId($request); + + if (! $id) { + return null; + } + + $workspace = Workspace::query()->whereKey($id)->first(); + + if (! $workspace) { + return null; + } + + if (! $this->isWorkspaceSelectable($workspace)) { + return null; + } + + return $workspace; + } + + public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void + { + $session = ($request && $request->hasSession()) ? $request->session() : session(); + $session->put(self::SESSION_KEY, (int) $workspace->getKey()); + + if ($user !== null) { + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + } + } + + public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void + { + $session = ($request && $request->hasSession()) ? $request->session() : session(); + $session->forget(self::SESSION_KEY); + + if ($user !== null && $user->last_workspace_id !== null) { + $user->forceFill(['last_workspace_id' => null])->save(); + } + } + + public function resolveInitialWorkspaceFor(User $user, ?Request $request = null): ?Workspace + { + $session = ($request && $request->hasSession()) ? $request->session() : session(); + + $currentId = $this->currentWorkspaceId($request); + + if ($currentId) { + $current = Workspace::query()->whereKey($currentId)->first(); + + if (! $current instanceof Workspace || ! $this->isWorkspaceSelectable($current) || ! $this->isMember($user, $current)) { + $session->forget(self::SESSION_KEY); + + if ((int) $user->last_workspace_id === (int) $currentId) { + $user->forceFill(['last_workspace_id' => null])->save(); + } + } else { + return $current; + } + } + + if ($user->last_workspace_id !== null) { + $workspace = Workspace::query()->whereKey($user->last_workspace_id)->first(); + + if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) { + $user->forceFill(['last_workspace_id' => null])->save(); + } else { + $session->put(self::SESSION_KEY, (int) $workspace->getKey()); + + return $workspace; + } + } + + $memberships = WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->with('workspace') + ->get(); + + $selectableWorkspaces = $memberships + ->map(fn (WorkspaceMembership $membership) => $membership->workspace) + ->filter(fn (?Workspace $workspace) => $workspace instanceof Workspace && $this->isWorkspaceSelectable($workspace)) + ->values(); + + if ($selectableWorkspaces->count() === 1) { + /** @var Workspace $workspace */ + $workspace = $selectableWorkspaces->first(); + + $session->put(self::SESSION_KEY, (int) $workspace->getKey()); + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + return $workspace; + } + + return null; + } + + public function isMember(User $user, Workspace $workspace): bool + { + return WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->where('workspace_id', $workspace->getKey()) + ->exists(); + } + + private function isWorkspaceSelectable(Workspace $workspace): bool + { + return empty($workspace->archived_at); + } +} diff --git a/app/Support/Workspaces/WorkspaceResolver.php b/app/Support/Workspaces/WorkspaceResolver.php new file mode 100644 index 0000000..5535db8 --- /dev/null +++ b/app/Support/Workspaces/WorkspaceResolver.php @@ -0,0 +1,25 @@ +where('slug', $value) + ->first(); + + if ($workspace !== null) { + return $workspace; + } + + if (! ctype_digit($value)) { + return null; + } + + return Workspace::query()->whereKey((int) $value)->first(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 5a58a69..c228817 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,8 @@ $middleware->alias([ 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, + 'ensure-workspace-selected' => \App\Http\Middleware\EnsureWorkspaceSelected::class, + 'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class, ]); $middleware->prependToPriorityList( diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php index 3abfbdd..36d8c49 100644 --- a/database/factories/TenantFactory.php +++ b/database/factories/TenantFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\Workspace; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,6 +18,7 @@ class TenantFactory extends Factory public function definition(): array { return [ + 'workspace_id' => Workspace::factory(), 'name' => fake()->company(), 'external_id' => fake()->uuid(), 'tenant_id' => fake()->uuid(), diff --git a/database/factories/WorkspaceFactory.php b/database/factories/WorkspaceFactory.php new file mode 100644 index 0000000..a3d2e94 --- /dev/null +++ b/database/factories/WorkspaceFactory.php @@ -0,0 +1,27 @@ + + */ +class WorkspaceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = $this->faker->company(); + + return [ + 'name' => $name, + 'slug' => Str::slug($name).'-'.$this->faker->unique()->randomNumber(5), + ]; + } +} diff --git a/database/factories/WorkspaceMembershipFactory.php b/database/factories/WorkspaceMembershipFactory.php new file mode 100644 index 0000000..eb48066 --- /dev/null +++ b/database/factories/WorkspaceMembershipFactory.php @@ -0,0 +1,25 @@ + + */ +class WorkspaceMembershipFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => \App\Models\Workspace::factory(), + 'user_id' => \App\Models\User::factory(), + 'role' => 'operator', + ]; + } +} diff --git a/database/migrations/2025_12_11_192942_add_is_current_to_tenants.php b/database/migrations/2025_12_11_192942_add_is_current_to_tenants.php index 6e16d4b..5421fef 100644 --- a/database/migrations/2025_12_11_192942_add_is_current_to_tenants.php +++ b/database/migrations/2025_12_11_192942_add_is_current_to_tenants.php @@ -52,6 +52,12 @@ public function up(): void ->where('tenant_id', 'local-tenant') ->update(['status' => 'archived', 'is_current' => false]); + $driver = Schema::getConnection()->getDriverName(); + + if ($driver === 'sqlite') { + return; + } + DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = true AND deleted_at IS NULL'); } diff --git a/database/migrations/2026_01_31_230301_create_workspaces_table.php b/database/migrations/2026_01_31_230301_create_workspaces_table.php new file mode 100644 index 0000000..1e87951 --- /dev/null +++ b/database/migrations/2026_01_31_230301_create_workspaces_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('slug')->nullable()->unique(); + $table->timestamps(); + + $table->index('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspaces'); + } +}; diff --git a/database/migrations/2026_01_31_230302_create_workspace_memberships_table.php b/database/migrations/2026_01_31_230302_create_workspace_memberships_table.php new file mode 100644 index 0000000..0670737 --- /dev/null +++ b/database/migrations/2026_01_31_230302_create_workspace_memberships_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('role', ['owner', 'manager', 'operator', 'readonly']); + $table->timestamps(); + + $table->unique(['workspace_id', 'user_id']); + $table->index(['workspace_id', 'role']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_memberships'); + } +}; diff --git a/database/migrations/2026_01_31_230303_add_last_workspace_id_to_users_table.php b/database/migrations/2026_01_31_230303_add_last_workspace_id_to_users_table.php new file mode 100644 index 0000000..fca1fbd --- /dev/null +++ b/database/migrations/2026_01_31_230303_add_last_workspace_id_to_users_table.php @@ -0,0 +1,32 @@ +foreignId('last_workspace_id') + ->nullable() + ->after('remember_token') + ->constrained('workspaces') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('last_workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php b/database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php new file mode 100644 index 0000000..ed21b10 --- /dev/null +++ b/database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php @@ -0,0 +1,34 @@ +foreignId('workspace_id') + ->nullable() + ->after('id') + ->constrained('workspaces') + ->nullOnDelete(); + + $table->index('workspace_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropConstrainedForeignId('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php b/database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php new file mode 100644 index 0000000..530422c --- /dev/null +++ b/database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php @@ -0,0 +1,164 @@ +getDriverName(); + + if ($driver === 'sqlite') { + Schema::disableForeignKeyConstraints(); + + Schema::rename('audit_logs', 'audit_logs_old'); + + foreach ([ + 'audit_logs_tenant_id_action_index', + 'audit_logs_tenant_id_resource_type_index', + 'audit_logs_recorded_at_index', + ] as $indexName) { + DB::statement("DROP INDEX IF EXISTS {$indexName}"); + } + + Schema::create('audit_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_email')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('action'); + $table->string('resource_type')->nullable(); + $table->string('resource_id')->nullable(); + $table->string('status')->default('success'); + $table->json('metadata')->nullable(); + $table->timestamp('recorded_at')->useCurrent(); + $table->timestamps(); + + $table->index(['tenant_id', 'action']); + $table->index(['tenant_id', 'resource_type']); + $table->index(['workspace_id', 'action']); + $table->index(['workspace_id', 'resource_type']); + $table->index('recorded_at'); + }); + + DB::table('audit_logs_old')->orderBy('id')->chunkById(500, function ($rows): void { + foreach ($rows as $row) { + DB::table('audit_logs')->insert([ + 'id' => $row->id, + 'tenant_id' => $row->tenant_id, + 'workspace_id' => null, + 'actor_id' => $row->actor_id, + 'actor_email' => $row->actor_email, + 'actor_name' => $row->actor_name, + 'action' => $row->action, + 'resource_type' => $row->resource_type, + 'resource_id' => $row->resource_id, + 'status' => $row->status, + 'metadata' => $row->metadata, + 'recorded_at' => $row->recorded_at, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]); + } + }, 'id'); + + Schema::drop('audit_logs_old'); + Schema::enableForeignKeyConstraints(); + + return; + } + + DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id DROP NOT NULL'); + + Schema::table('audit_logs', function (Blueprint $table) { + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete()->after('tenant_id'); + + $table->index(['workspace_id', 'action']); + $table->index(['workspace_id', 'resource_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $driver = Schema::getConnection()->getDriverName(); + + if ($driver === 'sqlite') { + Schema::disableForeignKeyConstraints(); + + Schema::rename('audit_logs', 'audit_logs_new'); + + foreach ([ + 'audit_logs_tenant_id_action_index', + 'audit_logs_tenant_id_resource_type_index', + 'audit_logs_recorded_at_index', + 'audit_logs_workspace_id_action_index', + 'audit_logs_workspace_id_resource_type_index', + ] as $indexName) { + DB::statement("DROP INDEX IF EXISTS {$indexName}"); + } + + Schema::create('audit_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_email')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('action'); + $table->string('resource_type')->nullable(); + $table->string('resource_id')->nullable(); + $table->string('status')->default('success'); + $table->json('metadata')->nullable(); + $table->timestamp('recorded_at')->useCurrent(); + $table->timestamps(); + + $table->index(['tenant_id', 'action']); + $table->index(['tenant_id', 'resource_type']); + $table->index('recorded_at'); + }); + + DB::table('audit_logs_new')->whereNotNull('tenant_id')->orderBy('id')->chunkById(500, function ($rows): void { + foreach ($rows as $row) { + DB::table('audit_logs')->insert([ + 'id' => $row->id, + 'tenant_id' => $row->tenant_id, + 'actor_id' => $row->actor_id, + 'actor_email' => $row->actor_email, + 'actor_name' => $row->actor_name, + 'action' => $row->action, + 'resource_type' => $row->resource_type, + 'resource_id' => $row->resource_id, + 'status' => $row->status, + 'metadata' => $row->metadata, + 'recorded_at' => $row->recorded_at, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]); + } + }, 'id'); + + Schema::drop('audit_logs_new'); + Schema::enableForeignKeyConstraints(); + + return; + } + + Schema::table('audit_logs', function (Blueprint $table) { + $table->dropConstrainedForeignId('workspace_id'); + $table->dropIndex(['workspace_id', 'action']); + $table->dropIndex(['workspace_id', 'resource_type']); + }); + + DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id SET NOT NULL'); + } +}; diff --git a/database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php b/database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php new file mode 100644 index 0000000..51305c0 --- /dev/null +++ b/database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php @@ -0,0 +1,145 @@ +where('slug', 'default') + ->value('id'); + + if (! $defaultWorkspaceId) { + $defaultWorkspaceId = DB::table('workspaces')->insertGetId([ + 'name' => 'Default Workspace', + 'slug' => 'default', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + if (Schema::hasTable('tenants') && Schema::hasColumn('tenants', 'workspace_id')) { + DB::table('tenants') + ->whereNull('workspace_id') + ->update([ + 'workspace_id' => $defaultWorkspaceId, + 'updated_at' => $now, + ]); + } + + if (! Schema::hasTable('workspace_memberships')) { + return; + } + + $roleRankToRole = [ + 4 => 'owner', + 3 => 'manager', + 2 => 'operator', + 1 => 'readonly', + ]; + + $userRoleRanks = collect(); + + if (Schema::hasTable('tenant_memberships')) { + $userRoleRanks = DB::table('tenant_memberships') + ->select([ + 'user_id', + DB::raw("MAX(CASE role WHEN 'owner' THEN 4 WHEN 'manager' THEN 3 WHEN 'operator' THEN 2 WHEN 'readonly' THEN 1 ELSE 0 END) AS role_rank"), + ]) + ->groupBy('user_id') + ->get(); + } + + $rows = []; + $userIds = []; + + foreach ($userRoleRanks as $row) { + $role = $roleRankToRole[(int) $row->role_rank] ?? null; + + if (! $role) { + continue; + } + + $rows[] = [ + 'workspace_id' => $defaultWorkspaceId, + 'user_id' => $row->user_id, + 'role' => $role, + 'created_at' => $now, + 'updated_at' => $now, + ]; + $userIds[] = $row->user_id; + } + + if (empty($rows) && Schema::hasTable('users')) { + $firstUserId = DB::table('users')->orderBy('id')->value('id'); + + if ($firstUserId) { + $rows[] = [ + 'workspace_id' => $defaultWorkspaceId, + 'user_id' => $firstUserId, + 'role' => 'owner', + 'created_at' => $now, + 'updated_at' => $now, + ]; + $userIds[] = $firstUserId; + } + } + + if (! empty($rows)) { + foreach (array_chunk($rows, 500) as $chunk) { + DB::table('workspace_memberships')->insertOrIgnore($chunk); + } + } + + $ownerCount = DB::table('workspace_memberships') + ->where('workspace_id', $defaultWorkspaceId) + ->where('role', 'owner') + ->count(); + + if ($ownerCount === 0) { + $firstMembershipId = DB::table('workspace_memberships') + ->where('workspace_id', $defaultWorkspaceId) + ->orderBy('id') + ->value('id'); + + if ($firstMembershipId) { + DB::table('workspace_memberships') + ->where('id', $firstMembershipId) + ->update([ + 'role' => 'owner', + 'updated_at' => $now, + ]); + } + } + + if (Schema::hasTable('users') && ! empty($userIds) && Schema::hasColumn('users', 'last_workspace_id')) { + DB::table('users') + ->whereIn('id', array_unique($userIds)) + ->whereNull('last_workspace_id') + ->update([ + 'last_workspace_id' => $defaultWorkspaceId, + 'updated_at' => $now, + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + return; + } +}; diff --git a/database/migrations/2026_02_01_085849_add_archived_at_to_workspaces_table.php b/database/migrations/2026_02_01_085849_add_archived_at_to_workspaces_table.php new file mode 100644 index 0000000..2d04576 --- /dev/null +++ b/database/migrations/2026_02_01_085849_add_archived_at_to_workspaces_table.php @@ -0,0 +1,30 @@ +timestamp('archived_at')->nullable()->after('slug'); + $table->index('archived_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workspaces', function (Blueprint $table) { + $table->dropIndex(['archived_at']); + $table->dropColumn('archived_at'); + }); + } +}; diff --git a/resources/views/filament/pages/choose-workspace.blade.php b/resources/views/filament/pages/choose-workspace.blade.php new file mode 100644 index 0000000..8c6a505 --- /dev/null +++ b/resources/views/filament/pages/choose-workspace.blade.php @@ -0,0 +1,38 @@ + +
+
+ Select a workspace to continue. +
+ + @php + $workspaces = $this->getWorkspaces(); + @endphp + + @if ($workspaces->isEmpty()) +
+ No active workspaces are available for your account. + You can create one using the button above. +
+ @else +
+ @foreach ($workspaces as $workspace) +
+
+
+ {{ $workspace->name }} +
+ + + Continue + +
+
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/filament/pages/no-access.blade.php b/resources/views/filament/pages/no-access.blade.php index b00fcc7..994c055 100644 --- a/resources/views/filament/pages/no-access.blade.php +++ b/resources/views/filament/pages/no-access.blade.php @@ -1,11 +1,12 @@
- You don’t have access to any tenants yet. + You don’t have access to any workspaces yet.
- Ask an administrator to add you to a tenant, then sign in again. + Ask an administrator to add you to a workspace. + If you are setting up a new account, you can also create a workspace using the button above.
diff --git a/resources/views/filament/pages/onboarding-managed-tenant.blade.php b/resources/views/filament/pages/onboarding-managed-tenant.blade.php new file mode 100644 index 0000000..46f012c --- /dev/null +++ b/resources/views/filament/pages/onboarding-managed-tenant.blade.php @@ -0,0 +1,5 @@ + +
+ Redirecting… +
+
diff --git a/routes/web.php b/routes/web.php index b1ef093..d0f941e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,7 +4,17 @@ use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\TenantOnboardingController; +use App\Filament\Pages\Tenancy\RegisterTenant; +use App\Filament\Resources\TenantResource\Pages\OnboardingManagedTenant; +use App\Models\Workspace; +use App\Support\Middleware\DenyNonMemberTenantAccess; +use App\Support\Workspaces\WorkspaceContext; +use App\Support\Workspaces\WorkspaceResolver; +use Filament\Http\Middleware\DisableBladeIconComponents; +use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Http\Middleware\Authenticate as FilamentAuthenticate; use Illuminate\Support\Facades\Route; +use Illuminate\Http\Request; Route::get('/', function () { return view('welcome'); @@ -16,6 +26,24 @@ Route::get('/admin/consent/start', TenantOnboardingController::class) ->name('admin.consent.start'); +// Fallback route: Filament's layout generates this URL when tenancy registration is enabled. +// In this app, package route registration may not always define it early enough, which breaks +// rendering on tenant-scoped routes. +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DenyNonMemberTenantAccess::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-selected', +]) + ->prefix('/admin') + ->name('filament.admin.') + ->get('/register-tenant', RegisterTenant::class) + ->name('tenant.registration'); + Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start']) ->name('admin.rbac.start'); @@ -28,3 +56,52 @@ Route::get('/auth/entra/callback', [EntraController::class, 'callback']) ->middleware('throttle:entra-callback') ->name('auth.entra.callback'); + +Route::middleware(['web', 'auth', 'ensure-workspace-selected']) + ->get('/admin/new', function (Request $request) { + $workspace = app(WorkspaceContext::class)->currentWorkspace($request); + + if (! $workspace instanceof Workspace) { + return redirect('/admin/choose-workspace'); + } + + return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding'); + }) + ->name('admin.legacy.onboarding'); + +Route::bind('workspace', function (string $value): Workspace { + /** @var WorkspaceResolver $resolver */ + $resolver = app(WorkspaceResolver::class); + + $workspace = $resolver->resolve($value); + + abort_unless($workspace instanceof Workspace, 404); + + return $workspace; +}); + +Route::middleware(['web', 'auth', 'ensure-workspace-member']) + ->prefix('/admin/w/{workspace}') + ->group(function (): void { + Route::get('/', fn () => redirect('/admin/tenants')) + ->name('admin.workspace.home'); + + Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping'); + + Route::get('/managed-tenants', fn () => redirect('/admin/tenants')) + ->name('admin.workspace.managed-tenants.index'); + + Route::get('/managed-tenants/onboarding', OnboardingManagedTenant::class) + ->name('admin.workspace.managed-tenants.onboarding'); + }); + +if (app()->runningUnitTests()) { + Route::middleware(['web', 'auth', 'ensure-workspace-selected']) + ->get('/admin/_test/workspace-context', function (Request $request) { + $workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId($request); + + return response()->json([ + 'workspace_id' => $workspaceId, + ]); + }); +} diff --git a/specs/068-workspaces-v2/checklists/requirements.md b/specs/068-workspaces-v2/checklists/requirements.md new file mode 100644 index 0000000..0a224a4 --- /dev/null +++ b/specs/068-workspaces-v2/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Workspace Model, Memberships & Managed Tenants (v2) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-31 +**Feature**: [../spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass completed on 2026-01-31. +- No unresolved ambiguities detected; assumptions captured under Requirements. diff --git a/specs/068-workspaces-v2/contracts/admin-workspaces.openapi.yaml b/specs/068-workspaces-v2/contracts/admin-workspaces.openapi.yaml new file mode 100644 index 0000000..dacd6e6 --- /dev/null +++ b/specs/068-workspaces-v2/contracts/admin-workspaces.openapi.yaml @@ -0,0 +1,61 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin (Workspace v2) + version: 0.1.0 + description: | + High-level contract for workspace-scoped admin entry points. + +servers: + - url: / + +paths: + /admin/choose-workspace: + get: + summary: Choose workspace page + description: Render a list of workspaces the current user is a member of. + responses: + '200': { description: OK } + '302': { description: Redirect (auto-select when only one membership) } + + /admin/no-access: + get: + summary: No access page + description: Neutral page for authenticated users with zero workspace memberships. + responses: + '200': { description: OK } + + /admin/w/{workspace}/managed-tenants: + get: + summary: List managed tenants (workspace-scoped) + parameters: + - name: workspace + in: path + required: true + schema: { type: string } + description: Workspace identifier (slug preferred; id fallback) + responses: + '200': { description: OK } + '404': { description: Not found (non-member) } + + /admin/w/{workspace}/managed-tenants/onboarding: + get: + summary: Start onboarding (workspace-scoped) + parameters: + - name: workspace + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } + '403': { description: Forbidden (member missing capability) } + '404': { description: Not found (non-member) } + +components: + securitySchemes: + sessionAuth: + type: apiKey + in: cookie + name: tenantpilot_session + +security: + - sessionAuth: [] diff --git a/specs/068-workspaces-v2/data-model.md b/specs/068-workspaces-v2/data-model.md new file mode 100644 index 0000000..2b65158 --- /dev/null +++ b/specs/068-workspaces-v2/data-model.md @@ -0,0 +1,81 @@ +# Data Model: Workspace Model, Memberships & Managed Tenants (v2) + +**Date**: 2026-01-31 +**Feature**: [spec.md](spec.md) + +## Entities + +### Workspace + +Represents an organization/customer scope. + +**Fields** +- `id` (bigint, PK) +- `name` (string, required) +- `slug` (string, nullable, unique) +- `status` (enum/string; values: `active`, `archived`; default `active`) +- `created_at`, `updated_at` + +**Relationships** +- hasMany `WorkspaceMembership` +- hasMany `ManagedTenant` + +**Validation / invariants** +- `name` required and non-empty +- `slug` unique when present +- archived workspaces cannot be selected as current workspace + +--- + +### WorkspaceMembership + +Associates a user to a workspace with one role. + +**Fields** +- `id` (bigint, PK) +- `workspace_id` (FK workspaces) +- `user_id` (FK users) +- `role` (enum/string; values: `owner`, `manager`, `operator`, `readonly`) +- `created_at`, `updated_at` + +**Constraints** +- Unique(`workspace_id`, `user_id`) + +**Invariants** +- Last-owner guard: there must always be at least one `owner` membership per workspace. + +--- + +### ManagedTenant + +A Microsoft Entra/Intune tenant managed inside exactly one Workspace. + +**Fields (v2 additions)** +- `workspace_id` (FK workspaces) +- `entra_tenant_id` (string) + +**Constraints** +- Unique(`entra_tenant_id`) globally +- Index(`workspace_id`) + +--- + +### User + +Existing identity row. + +**Fields (v2 additions)** +- `last_workspace_id` (nullable FK workspaces) + +**Relationships** +- hasMany `WorkspaceMembership` + +## State transitions + +- Workspace `status`: `active` → `archived` (archived should not be selectable as current workspace) +- Membership role changes: `owner|manager|operator|readonly` with server-side last-owner guard + +## Notes + +- URL identity uses `slug` when present, else `id`. +- Current workspace context is session-backed with a DB fallback via `users.last_workspace_id`. diff --git a/specs/068-workspaces-v2/plan.md b/specs/068-workspaces-v2/plan.md new file mode 100644 index 0000000..96e1950 --- /dev/null +++ b/specs/068-workspaces-v2/plan.md @@ -0,0 +1,192 @@ +# Implementation Plan: Workspace Model, Memberships & Managed Tenants (v2) + +**Branch**: `068-workspaces-v2` | **Date**: 2026-01-31 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Introduce a Workspace hierarchy as the primary scope for the tenant-plane admin panel: + +- Workspace-scoped routing (`/admin/w/{workspace}/...`) with deny-as-not-found (404 semantics) for non-members. +- Workspace memberships (owner/manager/operator/readonly) as the source of truth for access and capability derivation. +- Workspace switcher + choose-workspace/no-access entry flows with session + `users.last_workspace_id` persistence. +- Managed Tenants become children of Workspaces; onboarding entry becomes canonical under Workspace scope. +- Global search becomes workspace-safe (results only within current workspace; no leakage). + +If the repository also contains tenant-scoped routes (e.g. `/admin/t/{tenant}/...`), they must be made workspace-safe by ensuring the tenant belongs to the currently selected workspace (or by denying-as-not-found). + +Design decisions and rationale are captured in [research.md](research.md). + +## Technical Context + + + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4 +**Storage**: PostgreSQL (via Sail) +**Testing**: Pest v4 (+ PHPUnit v12 underneath) +**Target Platform**: Web application; local dev in Laravel Sail; deploy via containers (Dokploy) +**Project Type**: Web application (Laravel monolith) +**Performance Goals**: Admin UX: workspace-scoped pages should remain responsive (target < 200ms p95 for DB-only requests on typical datasets). +**Constraints**: +- Sail-first execution for all PHP/Artisan/Composer/Node commands. +- No full provider refactor; keep changes scoped to Workspace model, routing/scope, memberships, and managed tenant scoping. +- Preserve deny-as-not-found semantics for non-members; do not leak via global search. +**Scale/Scope**: Multi-workspace, multi-tenant-plane: 1 user may belong to many workspaces; each workspace may contain multiple managed tenants. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: clarify what is “last observed” vs snapshots/backups +- Read/write separation: any writes require preview + confirmation + audit + tests +- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php` +- Deterministic capabilities: capability derivation is testable (snapshot/golden tests) +- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; 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 + +**Gate evaluation (pre-Phase 0): PASS (with notes)** + +- Inventory-first: Not directly affected (workspace + membership + routing). +- Read/write separation: Workspace/membership mutations are DB-only; require confirmation for destructive-like actions (remove member / demote last owner attempt), emit audit entries, and add tests. +- Graph contract path: No new Graph calls introduced. +- Deterministic capabilities: Extend the canonical capability registry with workspace-plane capabilities; implement deterministic role → capability mapping and test it. +- RBAC-UX: Apply the same semantics with Workspace scope: + - Non-member workspace scope → deny-as-not-found. + - Member missing capability → forbidden. + - UI disabled vs hidden rules remain. +- Global search safety: Introduce workspace-scoped global search query behavior (no results outside current workspace). +- Run observability: Not applicable (no long-running operations planned). Membership changes still require audit logs. +- Badge semantics: Not expected to change for this feature. + +## 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/ +│ ├── Resources/ +│ │ ├── WorkspaceResource.php +│ │ └── ... +│ └── Pages/ +│ ├── ChooseWorkspace.php +│ └── NoAccess.php +├── Http/ +│ └── Middleware/ +│ ├── EnsureWorkspaceSelected.php +│ └── EnsureWorkspaceMember.php +├── Models/ +│ ├── Workspace.php +│ ├── WorkspaceMembership.php +│ └── Tenant.php (existing; updated to belong to Workspace) +├── Policies/ +│ ├── WorkspacePolicy.php +│ └── WorkspaceMembershipPolicy.php +├── Support/ +│ ├── Auth/ +│ │ ├── Capabilities.php (extend with workspace-plane capabilities) +│ │ └── WorkspaceRole.php (new enum) +│ └── Rbac/ +│ ├── UiEnforcement.php (extend or add workspace variant) +│ └── ... +└── Services/ + └── Auth/ + ├── RoleCapabilityMap.php (tenant-plane) + └── WorkspaceRoleCapabilityMap.php (new; workspace-plane) + +database/migrations/ +tests/Feature/ +routes/web.php +bootstrap/app.php (middleware registration; Laravel 12) +bootstrap/providers.php (panel providers; Laravel 11+ rule) +``` + +**Structure Decision**: Laravel monolith. Implement Workspace scope via middleware + Filament pages/resources under the existing admin panel. + +## Phase 0 — Outline & Research (output: research.md) + +Completed in [research.md](research.md). Key outcomes: + +- URL identity: slug preferred, id fallback. +- Current workspace persistence: session + `users.last_workspace_id`. +- Managed Tenant uniqueness: global unique `entra_tenant_id`. +- Zero memberships: neutral `/admin/no-access` page. +- Workspace-scoped routing + middleware enforcement. +- Global search scoped to active workspace. +- Workspace-level audit stream for membership mutations, implemented as `AuditLog`-compatible entries (stable action IDs; redacted). + +## Phase 1 — Design & Contracts + +### Data model + +See [data-model.md](data-model.md) for entities, constraints, and invariants. + +### Contracts + +High-level entry-point contract is captured in [contracts/admin-workspaces.openapi.yaml](contracts/admin-workspaces.openapi.yaml). + +### Quickstart + +Manual verification guidance is in [quickstart.md](quickstart.md). + +### Post-design Constitution Re-check + +Expected PASS: this feature is DB-only and authorization-heavy; ensure: +- deny-as-not-found for non-members is enforced at middleware + query levels, +- member-without-capability is 403, +- deterministic capability mapping tests exist, +- membership mutations are audited. + +## Phase 2 — Implementation Task Preview (for /speckit.tasks) + +Planned task groupings: + +1) Database migrations + models (workspaces, memberships, user last_workspace_id, tenant workspace_id) +2) Workspace context + middleware + routing (choose-workspace, no-access, `/admin/w/{workspace}` prefix) +3) RBAC/capabilities (capability registry additions + role maps + policies) +4) Filament resources/pages (Workspace CRUD, Members relation manager, managed tenant onboarding/list scoping) +5) Global search scoping (workspace-safe global search queries) +6) Auditing for membership mutations (AuditLog-compatible) +7) Workspace lifecycle (archive/unarchive + selection invalidation) +8) Deterministic capability mapping tests (golden/snapshot) +9) Tests (Pest): last-owner guard, 404/403 semantics, global search scoping, workspace selection flows, migration verification + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/068-workspaces-v2/quickstart.md b/specs/068-workspaces-v2/quickstart.md new file mode 100644 index 0000000..0adac96 --- /dev/null +++ b/specs/068-workspaces-v2/quickstart.md @@ -0,0 +1,45 @@ +# Quickstart: Workspace Model, Memberships & Managed Tenants (v2) + +**Date**: 2026-01-31 +**Feature**: [spec.md](spec.md) + +## Prereqs + +- Start Sail: `./vendor/bin/sail up -d` + +## Local setup (after implementation) + +- Run migrations: `./vendor/bin/sail artisan migrate` + +## Manual verification checklist + +1) **Workspace selection** +- Create two workspaces. +- Add the current user to both. +- Verify `/admin/choose-workspace` lists only those workspaces. +- Select one and confirm subsequent navigation stays scoped. + +2) **Zero memberships** +- Use a user with no workspace memberships. +- Enter admin panel entry point. +- Verify redirect to `/admin/no-access`. +- Verify the user can create a workspace and becomes an Owner. + +3) **Isolation semantics** +- Create Workspace A and Workspace B. +- Ensure user is member only of Workspace A. +- Attempt to open any Workspace B URL. +- Verify deny-as-not-found behavior. + +4) **Managed tenant scoping** +- Add a managed tenant inside Workspace A. +- Verify it does not show up in Workspace B. + +5) **Membership management + last owner** +- In a workspace with exactly one owner, attempt to remove/demote that owner. +- Verify the action is blocked and audited. + +## Test execution (after implementation) + +- Run targeted tests: `./vendor/bin/sail artisan test --compact --filter=Workspace` +- Run formatter: `./vendor/bin/sail bin pint --dirty` diff --git a/specs/068-workspaces-v2/research.md b/specs/068-workspaces-v2/research.md new file mode 100644 index 0000000..78f5b36 --- /dev/null +++ b/specs/068-workspaces-v2/research.md @@ -0,0 +1,105 @@ +# Research: Workspace Model, Memberships & Managed Tenants (v2) + +**Date**: 2026-01-31 +**Feature**: [spec.md](spec.md) + +This document consolidates the key design decisions needed to implement the spec in this repo. + +## Decisions + +### 1) Workspace identifier in URLs + +**Decision**: Prefer Workspace `slug` in URLs; fall back to numeric `id` when a workspace has no slug. + +**Rationale**: +- Human-friendly URLs for admins (`/admin/w/acme/...`). +- Keeps migration pain low because slugs can be introduced incrementally. + +**Alternatives considered**: +- Always numeric id: easiest but less readable. +- Slug-only required: clean but increases migration/validation burden. + +--- + +### 2) Persisting current workspace selection + +**Decision**: Persist `current_workspace_id` in session AND store a nullable `users.last_workspace_id`. + +**Rationale**: +- Session is canonical for the current request. +- DB persistence improves UX across new sessions / devices without requiring the user to re-select every time. + +**Alternatives considered**: +- Session only: simpler but annoying across logins. +- URL-only: makes deep links harder and doesn’t support “default workspace” semantics. + +--- + +### 3) Managed Tenant uniqueness + +**Decision**: Entra tenant id is globally unique (a Managed Tenant belongs to exactly one Workspace). + +**Rationale**: +- Avoids ambiguous ownership and accidental double-management of the same Microsoft tenant. +- Aligns with “no tenant-in-tenant” goal. + +**Alternatives considered**: +- Unique per workspace: enables duplicates but creates confusing operational ownership. + +--- + +### 4) Zero-membership entry behavior + +**Decision**: Show a neutral `/admin/no-access` page for users with 0 memberships (not 404). + +**Rationale**: +- Clear UX while still not leaking any workspace existence. + +**Alternatives considered**: +- 404: secure but confusing; users think the app is broken. + +--- + +### 5) How to scope the admin panel without Filament tenancy + +**Decision**: Make Workspace context a first-class routing concern (`/admin/w/{workspace}/...`) enforced via middleware + session context. Do not use Filament tenancy (`/admin/t/{tenant}`) as the primary structure. + +**Rationale**: +- Meets “no tenant-in-tenant” and removes the overloaded “tenant” concept in UI. +- Middleware is the cleanest place to enforce deny-as-not-found for non-members. + +**Alternatives considered**: +- Use Filament tenancy to represent workspaces: would keep the same tenancy mechanisms but continues the “tenant-in-tenant” confusion. + +--- + +### 6) Global search scoping + +**Decision**: Global search must be scoped to the active Workspace and return no results outside it. + +**Rationale**: +- Prevents leakage (no hints) and aligns with constitution RBAC-UX. +- Repo already has a tenant-scoped global search trait; we can introduce a workspace-scoped variant. + +**Alternatives considered**: +- Hide search results at render time: insufficient, because global search queries must also be scoped. + +--- + +### 7) Audit logging for workspace membership changes + +**Decision**: Introduce a workspace-level audit log stream for membership mutations. + +**Rationale**: +- Existing `audit_logs` table is tenant-scoped (requires `tenant_id`) and cannot represent workspace-only changes cleanly. +- Additive schema avoids breaking existing auditing. + +**Alternatives considered**: +- Make `audit_logs.tenant_id` nullable and add `workspace_id`: higher migration and code risk. + +## Open Questions (implementation-level) + +These are not spec ambiguities but implementation choices to resolve while coding: + +- Whether to build a generic “scope context” abstraction (tenant/workspace) or implement a workspace-specific parallel to the existing tenant helpers. +- How to progressively migrate existing Filament resources from `/admin/t/{tenant}` to `/admin/w/{workspace}` without breaking deep links (redirect strategy). diff --git a/specs/068-workspaces-v2/spec.md b/specs/068-workspaces-v2/spec.md new file mode 100644 index 0000000..84587cb --- /dev/null +++ b/specs/068-workspaces-v2/spec.md @@ -0,0 +1,214 @@ +# Feature Specification: Workspace Model, Memberships & Managed Tenants (v2) + +**Feature Branch**: `068-workspaces-v2` +**Created**: 2026-01-31 +**Status**: Draft +**Input**: User description: "Spec 068 v2 — Workspace Model, Memberships & Managed Tenants (admin panel scope; replace tenant-scoped UI with workspace-scoped hierarchy; add workspace memberships/roles, switcher, 404/403 semantics, audit logs, and default workspace migration)" + +## Clarifications + +### Session 2026-01-31 + +- Q: What should identify the workspace in URLs (id vs slug)? → A: Prefer `slug` in URLs; if missing, fall back to numeric `id`. +- Q: How should current workspace selection be persisted? → A: Persist in session and also store a nullable `last_workspace_id` on the user. +- Q: Should an Entra tenant id be globally unique or unique per workspace? → A: Global unique `entra_tenant_id` (a Managed Tenant belongs to exactly one Workspace). +- Q: What should users with 0 workspace memberships see? → A: A neutral `/admin/no-access` page (not 404). + +## Routing, Scoping & Selection Semantics + +### Routing “planes” + +This feature introduces **Workspace-scoped** admin routing and treats Workspace membership as a primary isolation boundary. + +- **Workspace plane (workspace-scoped)**: `/admin/w/{workspace}/...` +- **Entry points (unscoped)**: `/admin/choose-workspace`, `/admin/no-access` + +If the repository also has existing tenant-scoped routes (e.g. `/admin/t/{tenant}/...`), they MUST be made workspace-safe by ensuring the tenant belongs to the currently selected workspace (or by denying-as-not-found). + +### Workspace selection algorithm (deterministic) + +When a signed-in user enters the admin panel: + +1) If the session has a selected workspace and it is still valid (user is a member, workspace not archived) → use it. +2) Else if `users.last_workspace_id` is set and still valid → select it, store into session, and use it. +3) Else if the user has exactly 1 active membership → auto-select that workspace, store into session + `last_workspace_id`, and use it. +4) Else if the user has 2+ active memberships → route to `/admin/choose-workspace`. +5) Else (0 memberships) → route to `/admin/no-access`. + +If a selected workspace becomes invalid (membership removed or workspace archived), selection MUST be cleared and the algorithm re-run. + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Choose workspace and get correct scope (Priority: P1) + +As a signed-in user, I want the admin panel to require an active Workspace context so that everything I see and search is scoped to my current Workspace and cannot leak information about other Workspaces. + +**Why this priority**: This is the foundation for correct navigation, isolation, and predictable operations in enterprise / MSP usage. + +**Independent Test**: Can be tested by creating 2 workspaces, adding a user to only one, and verifying workspace-scoped navigation + 404 semantics for non-members. + +**Acceptance Scenarios**: + +1. **Given** a user who is a member of exactly one Workspace, **When** they enter the admin panel entry point, **Then** they are automatically routed into that Workspace context. +2. **Given** a user who is a member of multiple Workspaces, **When** they enter the admin panel entry point, **Then** they are routed to a "choose workspace" page and can select one. +3. **Given** a user who is not a member of Workspace A, **When** they attempt to access any Workspace A scoped URL, **Then** they receive a not-found outcome (deny-as-not-found; no hints). +4. **Given** a user is a member of Workspace A, **When** they switch to Workspace B, **Then** navigation and global search results reflect only data in Workspace B. + +--- + +### User Story 2 - Manage workspace members and roles (Priority: P2) + +As a Workspace Owner or Manager, I want to add and manage Workspace members and their roles so that the right people can access the Workspace with the right level of permissions. + +**Why this priority**: Multi-user operation is required for MSP/enterprise teams; role-based access is a core control. + +**Independent Test**: Can be tested by creating a workspace with 2+ users and validating add/change/remove behavior, including last-owner protections and audit entries. + +**Acceptance Scenarios**: + +1. **Given** a Workspace with an Owner, **When** the Owner adds an existing user as a member, **Then** the user can access the Workspace within the admin panel. +2. **Given** a Workspace member, **When** an Owner changes that member’s role, **Then** their effective permissions change accordingly. +3. **Given** a Workspace with exactly one Owner, **When** that Owner is removed or demoted, **Then** the system prevents the action and records that it was blocked. +4. **Given** a user who is a member but lacks a required capability for an action, **When** they try to execute the action, **Then** the action is blocked with a forbidden outcome (member → 403 semantics) and does not mutate data. + +--- + +### User Story 3 - Onboard a managed tenant inside a workspace (Priority: P3) + +As a Workspace Owner or Manager, I want to add a Managed Tenant into the current Workspace from a single canonical onboarding entry so that onboarding is consistent now and can later evolve into a wizard without changing the entry point. + +**Why this priority**: Managed Tenants are the core “things being managed” and must clearly live under a Workspace. + +**Independent Test**: Can be tested by adding a Managed Tenant under Workspace A and verifying it does not appear under Workspace B. + +**Acceptance Scenarios**: + +1. **Given** an active Workspace context, **When** an authorized member starts "add managed tenant" onboarding, **Then** the resulting Managed Tenant belongs to the current Workspace. +2. **Given** a Managed Tenant in Workspace A, **When** viewing managed tenants in Workspace B, **Then** the tenant is not visible. +3. **Given** legacy onboarding entry points, **When** a user visits them, **Then** they are redirected into the canonical onboarding entry for the appropriate Workspace context. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- User has zero Workspace memberships (newly provisioned account) → should see a neutral "no access" page without leaking other workspaces. +- Previously-selected Workspace is archived or the user’s membership was removed → current selection must be invalidated and user returned to choose/no-access safely. +- Two concurrent admins attempt to remove/demote the last owner at the same time → system still guarantees at least one owner. +- Managed Tenant identifier is attempted to be added twice (duplicate Entra tenant id) → the second attempt is blocked without creating an ambiguous partial record. +- Global search term matches records in a Workspace the user is not a member of → results must not hint those records exist. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes access control and tenant-plane data ownership. The implementation MUST include: +- tenant/workspace isolation guarantees, +- safety gates for any write/change behavior (preview/confirmation/audit as applicable), +- run visibility/traceability for any long-running work, +- and automated tests that prove isolation and authorization. + +If any security-relevant change intentionally skips run tracking, the implementation MUST still emit audit entries that allow incident review. + +**Constitution alignment (RBAC-UX):** This feature introduces Workspace-scoped authorization for the admin panel. The implementation MUST: +- explicitly enforce deny-as-not-found for non-members (no hints), +- enforce forbidden outcomes for members who lack the required capability, +- ensure global search is scoped to the active Workspace and non-member-safe, +- use a centralized capability registry and deterministic role mapping (no ad-hoc string checks), +- require explicit confirmation for destructive-like actions, +- and include at least one positive and one negative authorization test. + +**Constitution alignment (OPS-EX-AUTH-001):** Authentication handshakes may perform synchronous outbound requests during login, but this exception MUST NOT be used for monitoring/operations functionality. + +**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 + +- **FR-001 (Workspace entity)**: System MUST support a Workspace concept as a top-level container representing an organization/customer context. +- **FR-002 (Workspace lifecycle)**: Authorized users MUST be able to create a Workspace and view Workspace details. Workspaces MUST be markable as active or archived. +- **FR-003 (Membership source of truth)**: System MUST support Workspace membership records that assign a role per (workspace, user). Membership MUST be the sole source of truth for Workspace access. +- **FR-004 (Membership constraints)**: System MUST prevent duplicate memberships for the same (workspace, user) pair. +- **FR-005 (Last owner protection)**: System MUST prevent removing or demoting the last remaining Owner of a Workspace. +- **FR-006 (Membership management)**: Workspace Owners/Managers MUST be able to add existing users as members, change member roles, and remove members. +- **FR-007 (Workspace switcher + persistence)**: System MUST provide a workspace switcher that lists only workspaces where the user is a member, and MUST persist the user’s current workspace selection for the session. +- **FR-008 (Choose-workspace routing)**: If a user is a member of multiple workspaces, system MUST provide a dedicated choose-workspace page and MUST require an explicit selection before accessing workspace-scoped pages. +- **FR-008a (No-access routing)**: If a user has zero workspace memberships, system MUST route them to a neutral "no access" page and MUST NOT return deny-as-not-found for this entry-point case. +- **FR-009 (Workspace-scoped URLs)**: System MUST require Workspace context for all Workspace-plane admin resources under `/admin/w/{workspace}/...`, except authentication and the choose-workspace/no-access entry points. + - If tenant-scoped routes exist elsewhere (e.g. `/admin/t/{tenant}/...`), they MUST be workspace-safe by ensuring the tenant belongs to the currently selected workspace (or by denying-as-not-found). +- **FR-010 (Managed tenant belongs to workspace)**: Each Managed Tenant MUST belong to exactly one Workspace. +- **FR-011 (Managed tenant uniqueness)**: A Managed Tenant MUST be uniquely identifiable by its Entra tenant identifier, and the system MUST prevent duplicates. +- **FR-012 (Canonical onboarding entry)**: System MUST provide a single canonical "add managed tenant" entry inside Workspace context; legacy entry points MUST redirect to it without allowing creation in an incorrect scope. +- **FR-013 (Global search safety)**: Global search MUST return results only from the user’s current Workspace, and MUST not leak existence of records in other Workspaces. +- **FR-014 (404 vs 403 semantics)**: For any Workspace-scoped resource: + - Non-member access MUST be deny-as-not-found. + - Member access without the required capability MUST be forbidden. +- **FR-015 (Capabilities and role mapping)**: System MUST derive effective capabilities from Workspace role deterministically using a centralized role → capability mapping. +- **FR-016 (UI behavior for missing capability)**: For members without required capability, the UI MUST show relevant actions as disabled (where applicable) rather than hidden, and execution MUST still be blocked server-side. +- **FR-017 (Audit logging for membership changes)**: System MUST record audit events for membership add, role change, removal, and last-owner-blocked outcomes. + +#### Clarified Authorization Rules + +- Workspace creation: any signed-in user MAY create a Workspace; the creator becomes an Owner. +- Workspace membership management: only Workspace Owners/Managers. + +#### Assumptions + +- Creating a Workspace is allowed for signed-in users in v2; the creator becomes an Owner of the new Workspace. +- Workspace URL identifier prefers human-friendly `slug`, falling back to numeric `id` if a workspace does not have a slug. +- Current workspace selection is persisted in session and also saved as a nullable `last_workspace_id` on the user. + +#### Legacy Route Scope (explicit) + +For v2, the only guaranteed legacy onboarding entry point that MUST redirect is: + +- `/admin/new` + +Additional legacy routes may exist in the codebase; they should be enumerated and handled as part of implementation if discovered. + +#### Out of Scope (v2) + +- Onboarding wizard UI (stepper-based experience) +- Automated mapping from external groups to Workspace membership +- Billing and multi-region concerns + +### Key Entities *(include if feature involves data)* + +- **Workspace**: Represents an organization/customer scope; includes display name, optional human-friendly key, and an active/archived status. +- **Workspace Membership**: Associates a user to a workspace with one role (Owner/Manager/Operator/Readonly). +- **Managed Tenant**: Represents a Microsoft/Entra/Intune tenant managed inside a Workspace; belongs to exactly one Workspace and is uniquely identified by Entra tenant id. +- **Workspace Context**: The current active Workspace selection that scopes navigation and search. +- **Capability**: A named permission (e.g., workspace management, member management, managed tenant management) derived from Workspace role via centralized mapping. +- **Audit Event**: An immutable record of sensitive Workspace membership changes with minimal, non-secret details. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001 (Isolation)**: 100% of attempts by non-members to access a Workspace-scoped page result in a not-found outcome. +- **SC-002 (Correct scoping)**: 0 global search results are returned from outside the user’s current Workspace during acceptance testing. +- **SC-003 (Membership management)**: An Owner can add a member, change role, and remove member in under 2 minutes end-to-end using the admin UI. +- **SC-004 (Last-owner safety)**: 100% of attempts to remove/demote the last Owner are blocked and recorded. +- **SC-005 (Migration safety)**: After migration, 100% of existing Managed Tenants are associated with exactly one Workspace. diff --git a/specs/068-workspaces-v2/tasks.md b/specs/068-workspaces-v2/tasks.md new file mode 100644 index 0000000..588ed56 --- /dev/null +++ b/specs/068-workspaces-v2/tasks.md @@ -0,0 +1,175 @@ +# Tasks: Workspace Model, Memberships & Managed Tenants (v2) + +**Input**: Design documents from `specs/068-workspaces-v2/` +**Prerequisites**: `plan.md` (required), `spec.md` (user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md` + +**Tests**: Required (Pest) because this feature changes runtime behavior and authorization semantics. + +## Phase 1: Setup (Shared Infrastructure) + +- [X] T001 Ensure Sail services are running for development (docker-compose.yml) +- [X] T002 Verify admin panel provider registration (bootstrap/providers.php) +- [X] T003 [P] Create new feature test folder structure (tests/Feature/Workspaces/) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +- [X] T004 Create Workspace migrations (database/migrations/*_create_workspaces_table.php) +- [X] T005 Create WorkspaceMembership migrations (includes unique `(workspace_id, user_id)` constraint) (database/migrations/*_create_workspace_memberships_table.php) +- [X] T006 Add `users.last_workspace_id` migration (database/migrations/*_add_last_workspace_id_to_users_table.php) +- [X] T007 Add `tenants.workspace_id` migration + indexes + global unique entra tenant id constraint (workspace_id migration + existing `tenant_id` unique constraint) +- [X] T008 [P] Create `Workspace` model + relationships (app/Models/Workspace.php) +- [X] T009 [P] Create `WorkspaceMembership` model + relationships (app/Models/WorkspaceMembership.php) +- [X] T010 [P] Add `workspace()` relationship on Tenant model (app/Models/Tenant.php) +- [X] T011 Create Workspace role enum (app/Support/Auth/WorkspaceRole.php) +- [X] T012 Extend capability registry with workspace-plane capabilities (app/Support/Auth/Capabilities.php) +- [X] T013 Create workspace role→capability mapping (app/Services/Auth/WorkspaceRoleCapabilityMap.php) +- [X] T014 Implement workspace capability resolver (app/Services/Auth/WorkspaceCapabilityResolver.php) +- [X] T015 [P] Add Workspace + Membership policies for server-side enforcement (app/Policies/WorkspacePolicy.php) +- [X] T016 [P] Add WorkspaceMembership policy + last-owner guard hooks (app/Policies/WorkspaceMembershipPolicy.php) +- [X] T017 Implement workspace-scoped context helper (session + user last_workspace_id) (app/Support/Workspaces/WorkspaceContext.php) +- [X] T018 Implement workspace selection middleware (app/Http/Middleware/EnsureWorkspaceSelected.php) +- [X] T019 Implement workspace membership middleware (deny-as-not-found) (app/Http/Middleware/EnsureWorkspaceMember.php) +- [X] T020 Register middleware in Laravel 12 middleware pipeline (bootstrap/app.php) +- [X] T021 Add workspace resolver helper (slug preferred, id fallback) (app/Support/Workspaces/WorkspaceResolver.php) + +**Checkpoint**: Foundation ready (DB + capability system + middleware hooks exist) + +--- + +## Phase 3: User Story 1 — Choose workspace and get correct scope (Priority: P1) 🎯 MVP + +**Goal**: Users must always operate within an active Workspace context; all workspace-scoped routes are deny-as-not-found for non-members; global search never leaks records. + +**Independent Test**: A user with memberships can select/switch workspaces and only see/search within the current workspace; a non-member gets 404 semantics. + +### Tests (US1) + +- [X] T022 [P] [US1] Add workspace selection routing tests (covers session vs `last_workspace_id` precedence + invalidation) (tests/Feature/Workspaces/WorkspaceSelectionTest.php) +- [X] T023 [P] [US1] Add non-member isolation tests for workspace-scoped routes (tests/Feature/Workspaces/WorkspaceIsolationTest.php) +- [X] T024 [P] [US1] Add global search scoping tests (tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php) + +### Implementation (US1) + +- [X] T025 [US1] Create ChooseWorkspace page (admin panel) (app/Filament/Pages/ChooseWorkspace.php) +- [X] T026 [US1] Create NoAccess page (admin panel) (app/Filament/Pages/NoAccess.php) +- [X] T027 [US1] Add routes/entry behavior for `/admin/choose-workspace` + `/admin/no-access` (AdminPanelProvider authenticatedRoutes + EnsureWorkspaceSelected allowlist) +- [X] T028 [US1] Implement workspace switcher (user menu) wiring to WorkspaceContext (app/Providers/Filament/AdminPanelProvider.php) +- [X] T029 [US1] Introduce workspace route group `/admin/w/{workspace}` with required middleware (routes/web.php) +- [X] T030 [US1] Introduce workspace-scoped global search query trait (app/Filament/Concerns/ScopesGlobalSearchToWorkspace.php) +- [X] T031 [US1] Apply workspace global search scoping to workspace-owned resources (app/Filament/Resources/**) + +**Checkpoint**: User Story 1 works and is testable standalone. + +--- + +## Phase 4: User Story 2 — Manage workspace members and roles (Priority: P2) + +**Goal**: Workspace Owner/Manager can add members, change roles, and remove members; last-owner cannot be removed/demoted; all membership mutations are audited. + +**Independent Test**: From Workspace view, manage memberships; verify last-owner guard; verify audit entries. + +### Tests (US2) + +- [X] T032 [P] [US2] Add last-owner guard tests (tests/Feature/Workspaces/LastOwnerGuardTest.php) +- [X] T033 [P] [US2] Add membership mutation authorization tests (403 vs 404 semantics) (tests/Feature/Workspaces/WorkspaceMembershipAuthorizationTest.php) +- [X] T034 [P] [US2] Add audit logging tests for membership mutations (tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php) + +### Implementation (US2) + +- [X] T035 [US2] Add `workspace_id` support to AuditLog storage (nullable `tenant_id` where required) + indexes (database/migrations/*_add_workspace_id_to_audit_logs_table.php) +- [X] T036 [US2] Extend AuditLog model with optional `workspace()` relationship (app/Models/AuditLog.php) +- [X] T037 [US2] Implement workspace audit logger service writing AuditLog entries + stable action ids (app/Services/Audit/WorkspaceAuditLogger.php) +- [X] T038 [US2] Create WorkspaceResource CRUD with View page (required for global search) (app/Filament/Resources/Workspaces/WorkspaceResource.php) +- [X] T039 [US2] Add WorkspaceResource pages (List/Create/View/Edit) (app/Filament/Resources/Workspaces/Pages/) +- [X] T040 [US2] Implement Members relation manager on WorkspaceResource (app/Filament/Resources/Workspaces/RelationManagers/MembershipsRelationManager.php) +- [X] T041 [US2] Apply UI enforcement (disabled vs hidden + confirmation) for membership actions (app/Support/Rbac/UiEnforcement.php) +- [X] T042 [US2] Enforce last-owner guard server-side in membership mutations (app/Policies/WorkspaceMembershipPolicy.php) + +**Checkpoint**: User Story 2 works and is testable standalone. + +--- + +## Phase 5: User Story 3 — Onboard a managed tenant inside a workspace (Priority: P3) + +**Goal**: Managed Tenant CRUD/listing/onboarding is always workspace-scoped and uses the canonical onboarding entry under workspace. + +**Independent Test**: Add a managed tenant in Workspace A; it never appears in Workspace B; old entry points redirect. + +### Tests (US3) + +- [X] T043 [P] [US3] Add managed tenant scoping tests (tests/Feature/Workspaces/ManagedTenantScopingTest.php) +- [X] T044 [P] [US3] Add legacy entry-point redirect tests (tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php) + +### Implementation (US3) + +- [X] T045 [US3] Update tenant-plane naming/terminology in UI to “Managed Tenant” within workspace context (app/Filament/Resources/TenantResource.php) +- [X] T046 [US3] Scope managed tenant list query to current workspace (app/Filament/Resources/TenantResource/Pages/ListTenants.php) +- [X] T047 [US3] Ensure managed tenant create uses current workspace_id and blocks cross-workspace edits (app/Filament/Resources/TenantResource/Pages/CreateTenant.php) +- [X] T048 [US3] Implement canonical onboarding route under workspace scope (app/Filament/Resources/TenantResource/Pages/OnboardingManagedTenant.php) +- [X] T049 [US3] Redirect legacy `/admin/new` entry to choose-workspace or last_workspace onboarding (routes/web.php) + +**Checkpoint**: User Story 3 works and is testable standalone. + +--- + +## Final Phase: Migration, Polish & Cross-Cutting Concerns + +- [X] T050 Create migration to backfill Default Workspace + assign existing tenants + bootstrap owner membership (database/migrations/*_backfill_default_workspace_and_memberships.php) +- [X] T051 Add workspace selection invalidation when workspace archived or membership removed (app/Support/Workspaces/WorkspaceContext.php) +- [X] T052 Ensure workspace-scoped navigation labels/IA are consistent (resources/views/** and app/Filament/**) +- [X] T053 Run formatter on touched files using Sail (`bin pint --dirty`) (vendor/bin/sail) +- [X] T054 Run targeted test suite for this feature using Sail (`artisan test --compact --filter=Workspaces`) (vendor/bin/sail) +- [X] T055 Validate manual quickstart checklist remains accurate (specs/068-workspaces-v2/quickstart.md) + +--- + +## Additions (Consistency + Constitution Requirements) + +- [X] T056 [P] Add deterministic workspace capability mapping golden tests (tests/Feature/Workspaces/WorkspaceCapabilitiesTest.php) +- [X] T057 [P] Add unique workspace membership constraint test (tests/Feature/Workspaces/WorkspaceMembershipUniquenessTest.php) +- [X] T058 [P] Add unique Entra tenant id constraint test (tests/Feature/Workspaces/ManagedTenantUniquenessTest.php) +- [X] T059 [P] Add migration safety test for backfill (SC-005) (tests/Feature/Workspaces/WorkspaceBackfillMigrationTest.php) +- [X] T060 [P] Add workspace lifecycle tests (archive/unarchive + selection invalidation) (tests/Feature/Workspaces/WorkspaceLifecycleTest.php) +- [X] T061 Add `archived_at` (or equivalent) to workspaces table + index (database/migrations/*_add_archived_at_to_workspaces_table.php) +- [X] T062 Add archive/unarchive actions with confirmation + authorization in WorkspaceResource (app/Filament/Resources/WorkspaceResource.php) +- [X] T063 [US1] Add “Create Workspace” action on ChooseWorkspace/NoAccess pages (supports first workspace) (app/Filament/Pages/ChooseWorkspace.php and app/Filament/Pages/NoAccess.php) +- [X] T064 [P] Add test for creating the first workspace from no-access/choose-workspace flows (tests/Feature/Workspaces/WorkspaceCreationEntryFlowTest.php) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 (Setup) → Phase 2 (Foundational) → Phase 3 (US1) → Phase 4 (US2) → Phase 5 (US3) → Final Phase + +### User Story Dependencies + +- **US1** depends on Foundational (workspace context, middleware, capability registry). +- **US2** depends on Foundational (workspace models + policies) and benefits from US1 routing/context. +- **US3** depends on Foundational (workspace_id on managed tenants) and US1 routing/context. + +## Parallel Execution Examples + +### US1 + +- [P] Write tests in tests/Feature/Workspaces/WorkspaceSelectionTest.php, tests/Feature/Workspaces/WorkspaceIsolationTest.php, tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php +- Implement pages in app/Filament/Pages/ChooseWorkspace.php and app/Filament/Pages/NoAccess.php + +### US2 + +- [P] Write tests in tests/Feature/Workspaces/LastOwnerGuardTest.php, tests/Feature/Workspaces/WorkspaceMembershipAuthorizationTest.php, tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php +- Implement audit storage/model updates in app/Models/AuditLog.php + app/Services/Audit/WorkspaceAuditLogger.php + +### US3 + +- [P] Write tests in tests/Feature/Workspaces/ManagedTenantScopingTest.php and tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php +- Update tenant resource pages in app/Filament/Resources/TenantResource/Pages/ + +## Implementation Strategy + +- MVP scope is **US1 only** (workspace selection + scoping + search safety + deny-as-not-found isolation). +- Then implement US2 (memberships + auditing + last-owner guard). +- Then implement US3 (managed tenant scoping + canonical onboarding + redirects). diff --git a/tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php b/tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php index 0679983..cf7610e 100644 --- a/tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php +++ b/tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -16,6 +18,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); + $this->get('/admin/no-access')->assertOk(); $this->get('/admin/choose-tenant')->assertOk(); }); diff --git a/tests/Feature/Auth/NoAccessPageRendersTest.php b/tests/Feature/Auth/NoAccessPageRendersTest.php index 8842563..2397dcb 100644 --- a/tests/Feature/Auth/NoAccessPageRendersTest.php +++ b/tests/Feature/Auth/NoAccessPageRendersTest.php @@ -14,5 +14,5 @@ $response = $this->get('/admin/no-access'); $response->assertOk(); - $response->assertSee('You don’t have access to any tenants yet.'); + $response->assertSee('You don’t have access to any workspaces yet.'); }); diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php index 74b990b..e8a92af 100644 --- a/tests/Feature/BulkSyncPoliciesTest.php +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -11,6 +11,8 @@ uses(RefreshDatabase::class); test('policy sync updates selected policies from graph and updates the operation run', function () { + config()->set('graph.enabled', true); + $tenant = Tenant::factory()->create([ 'status' => 'active', ]); diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 7c2bd74..1722959 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -398,19 +398,29 @@ }); test('tenant can be archived and hidden from default lists', function () { - $tenant = Tenant::create([ + $workspace = \App\Models\Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-4', 'name' => 'Tenant 4', ]); $user = User::factory()->create(); $this->actingAs($user); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($tenant, true); - Livewire::test(ListTenants::class) + Livewire::actingAs($user) + ->test(ListTenants::class) ->callTableAction('archive', $tenant); expect(Tenant::count())->toBe(0); @@ -436,12 +446,16 @@ }); test('tenant table archive filter toggles active and archived tenants', function () { - $active = Tenant::create([ + $workspace = \App\Models\Workspace::factory()->create(); + + $active = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-active', 'name' => 'Active Tenant', ]); - $archived = Tenant::create([ + $archived = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-archived', 'name' => 'Archived Tenant', ]); @@ -450,13 +464,20 @@ $user = User::factory()->create(); $this->actingAs($user); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $active->getKey() => ['role' => 'owner'], $archived->getKey() => ['role' => 'owner'], ]); Filament::setTenant($active, true); - $component = Livewire::test(ListTenants::class) + $component = Livewire::actingAs($user) + ->test(ListTenants::class) ->assertSee($active->name) ->assertSee($archived->name); @@ -472,27 +493,38 @@ }); test('archived tenant can be restored from the table', function () { - $tenant = Tenant::create([ + $workspace = \App\Models\Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-restore', 'name' => 'Restore Tenant', ]); $tenant->delete(); - $contextTenant = Tenant::create([ + $contextTenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-restore-context', 'name' => 'Restore Context Tenant', ]); $user = User::factory()->create(); $this->actingAs($user); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], $contextTenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($contextTenant, true); - Livewire::test(ListTenants::class) + Livewire::actingAs($user) + ->test(ListTenants::class) ->set('tableFilters.trashed.value', 1) ->callTableAction('restore', $tenant); diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php index cde067b..4854aeb 100644 --- a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -28,16 +28,11 @@ }); test('tenant portfolio tenant view returns 404 for non-member tenant record', function () { - $user = User::factory()->create(); + [$user, $authorizedTenant] = createUserWithTenant(Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-view']), role: 'owner'); $this->actingAs($user); - $authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-view']); $unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-view']); - $user->tenants()->syncWithoutDetaching([ - $authorizedTenant->getKey() => ['role' => 'owner'], - ]); - $this->get(route('filament.admin.resources.tenants.view', array_merge( filamentTenantRouteParams($unauthorizedTenant), ['record' => $unauthorizedTenant], @@ -45,16 +40,11 @@ }); test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () { - $user = User::factory()->create(); + [$user, $authorizedTenant] = createUserWithTenant(Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-edit']), role: 'owner'); $this->actingAs($user); - $authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-edit']); $unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-edit']); - $user->tenants()->syncWithoutDetaching([ - $authorizedTenant->getKey() => ['role' => 'owner'], - ]); - $this->get(route('filament.admin.resources.tenants.edit', array_merge( filamentTenantRouteParams($unauthorizedTenant), ['record' => $unauthorizedTenant], @@ -62,23 +52,17 @@ }); test('tenant portfolio lists only tenants the user can access', function () { - $user = User::factory()->create(); - $this->actingAs($user); - - $authorizedTenant = Tenant::factory()->create([ + [$user, $authorizedTenant] = createUserWithTenant(Tenant::factory()->create([ 'tenant_id' => 'tenant-portfolio-authorized', 'name' => 'Authorized Tenant', - ]); + ]), role: 'owner'); + $this->actingAs($user); $unauthorizedTenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-portfolio-unauthorized', 'name' => 'Unauthorized Tenant', ]); - $user->tenants()->syncWithoutDetaching([ - $authorizedTenant->getKey() => ['role' => 'owner'], - ]); - $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant))) ->assertOk() ->assertSee($authorizedTenant->name) @@ -88,11 +72,19 @@ test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () { Bus::fake(); + $workspace = \App\Models\Workspace::factory()->create(); + + $tenantA = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-a']); + $tenantB = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-b']); + $user = User::factory()->create(); $this->actingAs($user); - - $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']); - $tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenantA->getKey() => ['role' => 'owner'], @@ -101,7 +93,8 @@ Filament::setTenant($tenantA, true); - Livewire::test(ListTenants::class) + Livewire::actingAs($user) + ->test(ListTenants::class) ->assertTableBulkActionVisible('syncSelected') ->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB])); @@ -118,10 +111,17 @@ test('tenant portfolio bulk sync is disabled for readonly users', function () { Bus::fake(); + $workspace = \App\Models\Workspace::factory()->create(); + $tenant = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-readonly']); + $user = User::factory()->create(); $this->actingAs($user); - - $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'readonly'], @@ -146,11 +146,18 @@ test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () { Bus::fake(); + $workspace = \App\Models\Workspace::factory()->create(); + $tenantA = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-mixed-a']); + $tenantB = Tenant::factory()->create(['workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-bulk-mixed-b']); + $user = User::factory()->create(); $this->actingAs($user); - - $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']); - $tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-b']); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenantA->getKey() => ['role' => 'owner'], diff --git a/tests/Feature/Filament/TenantRbacWizardTest.php b/tests/Feature/Filament/TenantRbacWizardTest.php index d9cd858..6d43d98 100644 --- a/tests/Feature/Filament/TenantRbacWizardTest.php +++ b/tests/Feature/Filament/TenantRbacWizardTest.php @@ -4,6 +4,8 @@ use App\Http\Controllers\RbacDelegatedAuthController; use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use Filament\Facades\Filament; @@ -20,7 +22,10 @@ function tenantWithApp(): Tenant { - return Tenant::create([ + $workspace = Workspace::factory()->create(); + + return Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-guid', 'name' => 'Tenant One', 'app_client_id' => 'client-123', @@ -29,16 +34,29 @@ function tenantWithApp(): Tenant ]); } +function prepareWorkspaceContextFor(User $user, Tenant $tenant): void +{ + WorkspaceMembership::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => $tenant->workspace_id])->save(); +} + test('rbac action prompts login when no delegated token', function () { $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + prepareWorkspaceContextFor($user, $tenant); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($tenant, true); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') ->setActionData([ 'role_definition_id' => 'role-1', @@ -56,6 +74,7 @@ function tenantWithApp(): Tenant $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + prepareWorkspaceContextFor($user, $tenant); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); @@ -137,7 +156,8 @@ public function request(string $method, string $path, array $options = []): Grap }; }); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') ->setActionData([ 'role_definition_id' => 'role-1', @@ -164,6 +184,7 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + prepareWorkspaceContextFor($user, $tenant); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); @@ -253,7 +274,8 @@ public function request(string $method, string $path, array $options = []): Grap }; }); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') ->setActionData([ 'role_definition_id' => 'role-1', @@ -278,6 +300,7 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + prepareWorkspaceContextFor($user, $tenant); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); @@ -360,7 +383,8 @@ public function request(string $method, string $path, array $options = []): Grap }; }); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') ->setActionData([ 'role_definition_id' => 'role-1', @@ -382,12 +406,14 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + prepareWorkspaceContextFor($user, $tenant); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($tenant, true); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') ->setActionData([ 'group_mode' => 'existing', @@ -401,12 +427,14 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + prepareWorkspaceContextFor($user, $tenant); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($tenant, true); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') ->setActionData([ 'group_mode' => 'existing', @@ -419,6 +447,7 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + prepareWorkspaceContextFor($user, $tenant); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); @@ -510,7 +539,8 @@ public function request(string $method, string $path, array $options = []): Grap expect($options)->toHaveKey('group-123'); expect($options['group-123'])->toContain('Ops Team'); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') ->setActionData([ 'group_mode' => 'existing', @@ -534,6 +564,7 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + prepareWorkspaceContextFor($user, $tenant); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); @@ -627,7 +658,8 @@ public function request(string $method, string $path, array $options = []): Grap expect($roles)->toHaveKey('role-1'); expect($roles['role-1'])->toContain('Policy and Profile Manager'); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') ->setActionData([ 'role_definition_id' => 'role-1', diff --git a/tests/Feature/Filament/TenantScopingTest.php b/tests/Feature/Filament/TenantScopingTest.php index 2804b0c..71dfa67 100644 --- a/tests/Feature/Filament/TenantScopingTest.php +++ b/tests/Feature/Filament/TenantScopingTest.php @@ -25,10 +25,10 @@ [$user] = createUserWithTenant($tenantA, role: 'owner'); $this->actingAs($user) - ->get('/admin/choose-tenant') - ->assertOk() - ->assertSee('Tenant A') - ->assertDontSee('Tenant B'); + ->get('/admin/choose-tenant') + ->assertOk() + ->assertSee('Tenant A') + ->assertDontSee('Tenant B'); }); it('scopes global search results to the current tenant and denies non-members', function (): void { diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index 1d0ff22..52d4e95 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -55,16 +55,26 @@ public function request(string $method, string $path, array $options = []): Grap $user = User::factory()->create(); $this->actingAs($user); - $contextTenant = Tenant::create([ + $workspace = \App\Models\Workspace::factory()->create(); + + $contextTenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-context', 'name' => 'Context Tenant', ]); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $contextTenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($contextTenant, true); - Livewire::test(CreateTenant::class) + Livewire::actingAs($user) + ->test(CreateTenant::class) ->fillForm([ 'name' => 'Contoso', 'environment' => 'other', @@ -79,7 +89,8 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first(); expect($tenant)->not->toBeNull(); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->callAction('verify'); $tenant->refresh(); @@ -136,17 +147,27 @@ public function request(string $method, string $path, array $options = []): Grap $user = User::factory()->create(); $this->actingAs($user); - $tenant = Tenant::create([ + $workspace = \App\Models\Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-error', 'name' => 'Error Tenant', ]); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($tenant, true); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->callAction('verify'); $tenant->refresh(); @@ -169,10 +190,19 @@ public function request(string $method, string $path, array $options = []): Grap $user = User::factory()->create(); $this->actingAs($user); - $tenant = Tenant::create([ + $workspace = \App\Models\Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-ui', 'name' => 'UI Tenant', ]); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); @@ -201,11 +231,20 @@ public function request(string $method, string $path, array $options = []): Grap $user = User::factory()->create(); $this->actingAs($user); - $tenant = Tenant::create([ + $workspace = \App\Models\Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-ui-list', 'name' => 'UI Tenant List', 'app_client_id' => 'client-123', ]); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], @@ -221,17 +260,27 @@ public function request(string $method, string $path, array $options = []): Grap $user = User::factory()->create(); $this->actingAs($user); - $tenant = Tenant::create([ + $workspace = \App\Models\Workspace::factory()->create(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), 'tenant_id' => 'tenant-ui-deactivate', 'name' => 'UI Tenant Deactivate', ]); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => 'owner'], ]); Filament::setTenant($tenant, true); - Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + Livewire::actingAs($user) + ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('archive') ->callMountedAction() ->assertHasNoActionErrors(); diff --git a/tests/Feature/LegacyOnboardingRedirectTest.php b/tests/Feature/LegacyOnboardingRedirectTest.php new file mode 100644 index 0000000..41d19c7 --- /dev/null +++ b/tests/Feature/LegacyOnboardingRedirectTest.php @@ -0,0 +1,7 @@ +skip('Moved to tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php'); diff --git a/tests/Feature/ManagedTenantScopingTest.php b/tests/Feature/ManagedTenantScopingTest.php new file mode 100644 index 0000000..41b2eca --- /dev/null +++ b/tests/Feature/ManagedTenantScopingTest.php @@ -0,0 +1,7 @@ +skip('Moved to tests/Feature/Workspaces/ManagedTenantScopingTest.php'); diff --git a/tests/Feature/Workspaces/LastOwnerGuardTest.php b/tests/Feature/Workspaces/LastOwnerGuardTest.php new file mode 100644 index 0000000..b8489fe --- /dev/null +++ b/tests/Feature/Workspaces/LastOwnerGuardTest.php @@ -0,0 +1,45 @@ +create(); + $owner = User::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($owner)->create(['role' => 'owner']); + + $ownerMembership = WorkspaceMembership::query() + ->where('workspace_id', $workspace->getKey()) + ->where('user_id', $owner->getKey()) + ->firstOrFail(); + + Livewire::actingAs($owner) + ->test(MembershipsRelationManager::class, [ + 'ownerRecord' => $workspace, + 'pageClass' => ViewWorkspace::class, + ]) + ->callTableAction('change_role', $ownerMembership, [ + 'role' => 'manager', + ]); + + expect($ownerMembership->refresh()->role)->toBe('owner'); + + Livewire::actingAs($owner) + ->test(MembershipsRelationManager::class, [ + 'ownerRecord' => $workspace, + 'pageClass' => ViewWorkspace::class, + ]) + ->callTableAction('remove', $ownerMembership); + + expect(WorkspaceMembership::query()->whereKey($ownerMembership->getKey())->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php b/tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php new file mode 100644 index 0000000..fb434be --- /dev/null +++ b/tests/Feature/Workspaces/LegacyOnboardingRedirectTest.php @@ -0,0 +1,38 @@ +create(); + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']); + WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + + $this->get('/admin/new') + ->assertRedirect('/admin/choose-workspace'); +}); + +it('redirects legacy onboarding entry to canonical workspace onboarding when a workspace is resolved', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + + $canonicalUrl = '/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding'; + + $this->get('/admin/new') + ->assertRedirect($canonicalUrl); +}); diff --git a/tests/Feature/Workspaces/ManagedTenantScopingTest.php b/tests/Feature/Workspaces/ManagedTenantScopingTest.php new file mode 100644 index 0000000..ddfb322 --- /dev/null +++ b/tests/Feature/Workspaces/ManagedTenantScopingTest.php @@ -0,0 +1,60 @@ +create(); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']); + WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'name' => 'Tenant Alpha', + 'is_current' => true, + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'name' => 'Tenant Beta', + 'is_current' => false, + ]); + + $this->actingAs($user); + session([WorkspaceContext::SESSION_KEY => $workspaceA->getKey()]); + + $tenantIds = TenantResource::getEloquentQuery()->pluck('id')->all(); + + expect($tenantIds) + ->toContain($tenantA->getKey()) + ->not()->toContain($tenantB->getKey()); +}); + +it('returns no managed tenants when no workspace is selected', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $otherWorkspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + WorkspaceMembership::factory()->for($otherWorkspace)->for($user)->create(['role' => 'owner']); + Tenant::factory()->create(['workspace_id' => $workspace->getKey()]); + + $this->actingAs($user); + + $tenantIds = TenantResource::getEloquentQuery()->pluck('id')->all(); + + expect($tenantIds)->toBeEmpty(); +}); diff --git a/tests/Feature/Workspaces/ManagedTenantUniquenessTest.php b/tests/Feature/Workspaces/ManagedTenantUniquenessTest.php new file mode 100644 index 0000000..cd00e3e --- /dev/null +++ b/tests/Feature/Workspaces/ManagedTenantUniquenessTest.php @@ -0,0 +1,20 @@ +create([ + 'tenant_id' => '00000000-0000-0000-0000-000000000001', + 'external_id' => '00000000-0000-0000-0000-000000000001', + 'is_current' => true, + ]); + + expect(fn () => Tenant::factory()->create([ + 'tenant_id' => '00000000-0000-0000-0000-000000000001', + 'external_id' => '00000000-0000-0000-0000-000000000002', + 'is_current' => false, + ]))->toThrow(QueryException::class); +}); diff --git a/tests/Feature/Workspaces/WorkspaceBackfillMigrationTest.php b/tests/Feature/Workspaces/WorkspaceBackfillMigrationTest.php new file mode 100644 index 0000000..10e66e5 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceBackfillMigrationTest.php @@ -0,0 +1,101 @@ +delete(); + + $tenantA = Tenant::factory()->create(['workspace_id' => null, 'is_current' => true]); + $tenantB = Tenant::factory()->create(['workspace_id' => null, 'is_current' => false]); + + $userOwner = User::factory()->create(); + $userReadonly = User::factory()->create(); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantA->getKey(), + 'user_id' => $userOwner->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'user_id' => $userReadonly->getKey(), + 'role' => 'readonly', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $migration = require workspaceBackfillMigrationPath(); + $migration->up(); + + $defaultWorkspace = Workspace::query()->where('slug', 'default')->first(); + + expect($defaultWorkspace)->not->toBeNull(); + + $tenantA->refresh(); + $tenantB->refresh(); + + expect($tenantA->workspace_id)->toBe($defaultWorkspace->getKey()) + ->and($tenantB->workspace_id)->toBe($defaultWorkspace->getKey()); + + $memberships = DB::table('workspace_memberships') + ->where('workspace_id', $defaultWorkspace->getKey()) + ->get(['user_id', 'role']); + + expect($memberships)->toHaveCount(2) + ->and($memberships->firstWhere('user_id', $userOwner->getKey())->role)->toBe('owner') + ->and($memberships->firstWhere('user_id', $userReadonly->getKey())->role)->toBe('readonly'); + + $userOwner->refresh(); + $userReadonly->refresh(); + + expect($userOwner->last_workspace_id)->toBe($defaultWorkspace->getKey()) + ->and($userReadonly->last_workspace_id)->toBe($defaultWorkspace->getKey()); +}); + +it('is idempotent when run multiple times', function () { + Workspace::query()->delete(); + + $tenant = Tenant::factory()->create(['workspace_id' => null]); + + $user = User::factory()->create(); + + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'manager', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $migration = require workspaceBackfillMigrationPath(); + + $migration->up(); + $migration->up(); + + $defaultWorkspace = Workspace::query()->where('slug', 'default')->firstOrFail(); + + expect(Workspace::query()->where('slug', 'default')->count())->toBe(1); + + $tenant->refresh(); + expect($tenant->workspace_id)->toBe($defaultWorkspace->getKey()); + + expect(DB::table('workspace_memberships') + ->where('workspace_id', $defaultWorkspace->getKey()) + ->where('user_id', $user->getKey()) + ->count())->toBe(1); +}); diff --git a/tests/Feature/Workspaces/WorkspaceCapabilitiesTest.php b/tests/Feature/Workspaces/WorkspaceCapabilitiesTest.php new file mode 100644 index 0000000..a35902c --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceCapabilitiesTest.php @@ -0,0 +1,42 @@ +toEqual([ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MANAGE, + Capabilities::WORKSPACE_ARCHIVE, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, + ]); + + expect(WorkspaceRoleCapabilityMap::getCapabilities(WorkspaceRole::Manager))->toEqual([ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, + ]); + + expect(WorkspaceRoleCapabilityMap::getCapabilities(WorkspaceRole::Operator))->toEqual([ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + ]); + + expect(WorkspaceRoleCapabilityMap::getCapabilities(WorkspaceRole::Readonly))->toEqual([ + Capabilities::WORKSPACE_VIEW, + ]); +}); + +it('does not grant unknown capabilities via workspace role map', function () { + $allCapabilities = Capabilities::all(); + + foreach (WorkspaceRole::cases() as $role) { + foreach (WorkspaceRoleCapabilityMap::getCapabilities($role) as $capability) { + expect($allCapabilities)->toContain($capability); + } + } +}); diff --git a/tests/Feature/Workspaces/WorkspaceCreationEntryFlowTest.php b/tests/Feature/Workspaces/WorkspaceCreationEntryFlowTest.php new file mode 100644 index 0000000..cd6868f --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceCreationEntryFlowTest.php @@ -0,0 +1,62 @@ +create(); + $this->actingAs($user); + + Livewire::test(NoAccess::class) + ->mountAction('createWorkspace') + ->set('mountedActions.0.data.name', 'Acme') + ->set('mountedActions.0.data.slug', 'acme') + ->callMountedAction() + ->assertHasNoActionErrors(); + + $workspace = Workspace::query()->where('slug', 'acme')->firstOrFail(); + + expect(WorkspaceMembership::query() + ->where('workspace_id', $workspace->getKey()) + ->where('user_id', $user->getKey()) + ->where('role', 'owner') + ->exists())->toBeTrue(); + + expect(session()->get(WorkspaceContext::SESSION_KEY))->toBe($workspace->getKey()); + expect($user->fresh()->last_workspace_id)->toBe($workspace->getKey()); +}); + +it('allows creating a new workspace from the choose-workspace page', function () { + $user = User::factory()->create(); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']); + WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + + Livewire::test(ChooseWorkspace::class) + ->mountAction('createWorkspace') + ->set('mountedActions.0.data.name', 'New Workspace') + ->set('mountedActions.0.data.slug', 'new-workspace') + ->callMountedAction() + ->assertHasNoActionErrors(); + + $workspace = Workspace::query()->where('slug', 'new-workspace')->firstOrFail(); + + expect(WorkspaceMembership::query() + ->where('workspace_id', $workspace->getKey()) + ->where('user_id', $user->getKey()) + ->where('role', 'owner') + ->exists())->toBeTrue(); + + expect(session()->get(WorkspaceContext::SESSION_KEY))->toBe($workspace->getKey()); + expect($user->fresh()->last_workspace_id)->toBe($workspace->getKey()); +}); diff --git a/tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php b/tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php new file mode 100644 index 0000000..1fba1d6 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceGlobalSearchTest.php @@ -0,0 +1,56 @@ +create(); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']); + WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']); + + $tenantA = Tenant::factory()->create([ + 'name' => 'Tenant Alpha', + 'workspace_id' => $workspaceA->getKey(), + 'is_current' => true, + ]); + + $tenantB = Tenant::factory()->create([ + 'name' => 'Tenant Gamma', + 'workspace_id' => $workspaceB->getKey(), + 'is_current' => false, + ]); + + $this->actingAs($user); + session([WorkspaceContext::SESSION_KEY => $workspaceA->getKey()]); + + $tenantIds = TenantResource::getGlobalSearchEloquentQuery()->pluck('id')->all(); + + expect($tenantIds) + ->toContain($tenantA->getKey()) + ->not()->toContain($tenantB->getKey()); +}); + +it('returns no global search results when no workspace is selected', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + Tenant::factory()->create(['workspace_id' => $workspace->getKey()]); + + $this->actingAs($user); + + $tenantIds = TenantResource::getGlobalSearchEloquentQuery()->pluck('id')->all(); + + expect($tenantIds)->toBeEmpty(); +}); diff --git a/tests/Feature/Workspaces/WorkspaceIsolationTest.php b/tests/Feature/Workspaces/WorkspaceIsolationTest.php new file mode 100644 index 0000000..c5ebfeb --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceIsolationTest.php @@ -0,0 +1,47 @@ +create(); + $workspace = Workspace::factory()->create(); + + $this->actingAs($user); + + $this->get("/admin/w/{$workspace->getKey()}/ping") + ->assertNotFound(); +}); + +it('allows members to access workspace-scoped routes', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + + $this->get("/admin/w/{$workspace->getKey()}/ping") + ->assertNoContent(); +}); + +it('redirects members from the workspace root into the admin panel', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + + $this->get("/admin/w/{$workspace->getKey()}") + ->assertRedirect('/admin/tenants'); +}); + +it('denies non-members with not-found semantics for the workspace root', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $this->actingAs($user); + + $this->get("/admin/w/{$workspace->getKey()}") + ->assertNotFound(); +}); diff --git a/tests/Feature/Workspaces/WorkspaceLifecycleTest.php b/tests/Feature/Workspaces/WorkspaceLifecycleTest.php new file mode 100644 index 0000000..f6b9235 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceLifecycleTest.php @@ -0,0 +1,68 @@ +create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + $workspace->forceFill(['archived_at' => now()])->save(); + + $this->actingAs($user); + session([WorkspaceContext::SESSION_KEY => $workspace->getKey()]); + + $this->get('/admin/_test/workspace-context') + ->assertRedirect('/admin/no-access'); + + expect(session()->get(WorkspaceContext::SESSION_KEY))->toBeNull(); + expect($user->fresh()->last_workspace_id)->toBeNull(); +}); + +it('auto-selects another active workspace when the current selection becomes archived', function () { + $user = User::factory()->create(); + + $archived = Workspace::factory()->create(); + $active = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($archived)->for($user)->create(['role' => 'owner']); + WorkspaceMembership::factory()->for($active)->for($user)->create(['role' => 'owner']); + + $archived->forceFill(['archived_at' => now()])->save(); + + $this->actingAs($user); + session([WorkspaceContext::SESSION_KEY => $archived->getKey()]); + $user->forceFill(['last_workspace_id' => $archived->getKey()])->save(); + + $this->get('/admin/_test/workspace-context') + ->assertSuccessful() + ->assertJson([ + 'workspace_id' => $active->getKey(), + ]); + + expect(session()->get(WorkspaceContext::SESSION_KEY))->toBe($active->getKey()); + expect($user->fresh()->last_workspace_id)->toBe($active->getKey()); +}); + +it('invalidates selection and redirects to no-access when membership is removed', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $membership = WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + session([WorkspaceContext::SESSION_KEY => $workspace->getKey()]); + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); + + $membership->delete(); + + $this->get('/admin/_test/workspace-context') + ->assertRedirect('/admin/no-access'); + + expect(session()->get(WorkspaceContext::SESSION_KEY))->toBeNull(); + expect($user->fresh()->last_workspace_id)->toBeNull(); +}); diff --git a/tests/Feature/Workspaces/WorkspaceLogoutBypassTest.php b/tests/Feature/Workspaces/WorkspaceLogoutBypassTest.php new file mode 100644 index 0000000..43a86fd --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceLogoutBypassTest.php @@ -0,0 +1,16 @@ +create(); + + $this->actingAs($user); + + $this->post('/admin/logout') + ->assertRedirect('/admin/login'); + + expect(auth()->check())->toBeFalse(); +}); diff --git a/tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php b/tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php new file mode 100644 index 0000000..ab57827 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceMembershipAuditTest.php @@ -0,0 +1,46 @@ +create(); + $actor = User::factory()->create(); + $member = User::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($actor)->create(['role' => 'owner']); + + /** @var WorkspaceMembershipManager $manager */ + $manager = app(WorkspaceMembershipManager::class); + $manager->addMember( + workspace: $workspace, + actor: $actor, + member: $member, + role: 'readonly', + source: 'manual', + ); + + $log = AuditLog::query() + ->where('workspace_id', $workspace->getKey()) + ->where('action', AuditActionId::WorkspaceMembershipAdd->value) + ->orderByDesc('id') + ->first(); + + expect($log)->not->toBeNull(); + expect($log?->tenant_id)->toBeNull(); + expect($log?->status)->toBe('success'); + expect($log?->metadata)->toMatchArray([ + 'member_user_id' => $member->getKey(), + 'role' => 'readonly', + 'source' => 'manual', + ]); +}); diff --git a/tests/Feature/Workspaces/WorkspaceMembershipAuthorizationTest.php b/tests/Feature/Workspaces/WorkspaceMembershipAuthorizationTest.php new file mode 100644 index 0000000..5fbd9d4 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceMembershipAuthorizationTest.php @@ -0,0 +1,37 @@ +create(); + $workspace = Workspace::factory()->create(); + + $this->actingAs($user); + + Livewire::test(ViewWorkspace::class, ['record' => $workspace->getRouteKey()]) + ->assertStatus(404); +}); + +it('returns 403 for members without membership manage capability', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'readonly']); + + $this->actingAs($user); + + expect(fn () => Gate::forUser($user)->authorize(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, $workspace)) + ->toThrow(AuthorizationException::class); +}); diff --git a/tests/Feature/Workspaces/WorkspaceMembershipUniquenessTest.php b/tests/Feature/Workspaces/WorkspaceMembershipUniquenessTest.php new file mode 100644 index 0000000..13080df --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceMembershipUniquenessTest.php @@ -0,0 +1,18 @@ +create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + expect(fn () => WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'operator'])) + ->toThrow(QueryException::class); +}); diff --git a/tests/Feature/Workspaces/WorkspaceSelectionTest.php b/tests/Feature/Workspaces/WorkspaceSelectionTest.php new file mode 100644 index 0000000..ad8ca9a --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceSelectionTest.php @@ -0,0 +1,79 @@ +create(); + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + session([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => $workspace->getKey()]); + + $this->get('/admin/_test/workspace-context') + ->assertSuccessful() + ->assertJson([ + 'workspace_id' => $workspace->getKey(), + ]); +}); + +it('falls back to last_workspace_id when session workspace is invalid', function () { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); + + $this->actingAs($user); + session([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => 999999]); + + $this->get('/admin/_test/workspace-context') + ->assertSuccessful() + ->assertJson([ + 'workspace_id' => $workspace->getKey(), + ]); +}); + +it('auto-selects the only membership workspace when nothing is selected', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspace)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + + $this->get('/admin/_test/workspace-context') + ->assertSuccessful() + ->assertJson([ + 'workspace_id' => $workspace->getKey(), + ]); + + expect($user->fresh()->last_workspace_id)->toBe($workspace->getKey()); +}); + +it('redirects to no-access when user has zero workspace memberships', function () { + $user = User::factory()->create(); + + $this->actingAs($user); + + $this->get('/admin/_test/workspace-context') + ->assertRedirect('/admin/no-access'); +}); + +it('redirects to choose-workspace when user has multiple workspaces and none selected', function () { + $user = User::factory()->create(); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->for($workspaceA)->for($user)->create(['role' => 'owner']); + WorkspaceMembership::factory()->for($workspaceB)->for($user)->create(['role' => 'owner']); + + $this->actingAs($user); + + $this->get('/admin/_test/workspace-context') + ->assertRedirect('/admin/choose-workspace'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index ebe72a2..3a29bb7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,8 @@ use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use App\Services\Graph\GraphClientInterface; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\Support\AssertsNoOutboundHttp; @@ -90,6 +92,25 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string $user ??= User::factory()->create(); $tenant ??= Tenant::factory()->create(); + $workspace = $tenant->workspace; + + if (! $workspace instanceof Workspace) { + $workspace = Workspace::factory()->create(); + $tenant->forceFill(['workspace_id' => $workspace->getKey()])->save(); + } + + WorkspaceMembership::query()->firstOrCreate( + [ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + ], + [ + 'role' => $role, + ], + ); + + $user->forceFill(['last_workspace_id' => $workspace->getKey()])->save(); + $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => $role], ]); diff --git a/tests/Unit/AssignmentRestoreServiceTest.php b/tests/Unit/AssignmentRestoreServiceTest.php index ef12ecb..a0dbb96 100644 --- a/tests/Unit/AssignmentRestoreServiceTest.php +++ b/tests/Unit/AssignmentRestoreServiceTest.php @@ -42,6 +42,7 @@ it('uses the contract assignment payload key for assign actions', function () { $tenant = Tenant::factory()->make([ + 'workspace_id' => 1, 'tenant_id' => 'tenant-123', 'app_client_id' => null, 'app_client_secret' => null, @@ -93,6 +94,7 @@ it('uses derived assign endpoints for app protection policies', function () { $tenant = Tenant::factory()->make([ + 'workspace_id' => 1, 'tenant_id' => 'tenant-123', 'app_client_id' => null, 'app_client_secret' => null, @@ -141,6 +143,7 @@ it('maps assignment filter ids stored at the root of assignments', function () { $tenant = Tenant::factory()->make([ + 'workspace_id' => 1, 'tenant_id' => 'tenant-123', 'app_client_id' => null, 'app_client_secret' => null, @@ -201,6 +204,7 @@ it('keeps assignment filters when mapping is missing but filter exists in target', function () { $tenant = Tenant::factory()->make([ + 'workspace_id' => 1, 'tenant_id' => 'tenant-123', 'app_client_id' => null, 'app_client_secret' => null, diff --git a/tests/Unit/Support/Rbac/UiEnforcementTest.php b/tests/Unit/Support/Rbac/UiEnforcementTest.php index 4f5f0a2..d50ca31 100644 --- a/tests/Unit/Support/Rbac/UiEnforcementTest.php +++ b/tests/Unit/Support/Rbac/UiEnforcementTest.php @@ -11,7 +11,7 @@ it('correctly identifies non-member as deny-as-not-found', function () { $context = new TenantAccessContext( user: User::factory()->make(), - tenant: Tenant::factory()->make(), + tenant: Tenant::factory()->make(['workspace_id' => 1]), isMember: false, hasCapability: false, ); @@ -24,7 +24,7 @@ it('correctly identifies member without capability as forbidden', function () { $context = new TenantAccessContext( user: User::factory()->make(), - tenant: Tenant::factory()->make(), + tenant: Tenant::factory()->make(['workspace_id' => 1]), isMember: true, hasCapability: false, ); @@ -37,7 +37,7 @@ it('correctly identifies authorized member', function () { $context = new TenantAccessContext( user: User::factory()->make(), - tenant: Tenant::factory()->make(), + tenant: Tenant::factory()->make(['workspace_id' => 1]), isMember: true, hasCapability: true, );