From ea526b255ad7084444456260f2e4da01664dd4f7 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:59:10 +0100 Subject: [PATCH] feat: workspace foundation + workspace-scoped tenant selection --- app/Filament/Pages/ChooseWorkspace.php | 137 +++++++++ app/Filament/Pages/NoAccess.php | 63 ++++ .../Workspaces/Pages/CreateWorkspace.php | 35 +++ .../Workspaces/Pages/EditWorkspace.php | 11 + .../Workspaces/Pages/ListWorkspaces.php | 19 ++ .../Workspaces/WorkspaceResource.php | 62 ++++ app/Http/Middleware/EnsureWorkspaceMember.php | 51 ++++ .../Middleware/EnsureWorkspaceSelected.php | 67 +++++ app/Models/Tenant.php | 6 + app/Models/User.php | 8 + app/Models/Workspace.php | 43 +++ app/Models/WorkspaceMembership.php | 31 ++ app/Policies/WorkspaceMembershipPolicy.php | 108 +++++++ app/Policies/WorkspacePolicy.php | 74 +++++ app/Providers/Filament/AdminPanelProvider.php | 4 + app/Services/Audit/WorkspaceAuditLogger.php | 40 +++ .../Auth/WorkspaceCapabilityResolver.php | 100 +++++++ .../Auth/WorkspaceMembershipManager.php | 272 ++++++++++++++++++ .../Auth/WorkspaceRoleCapabilityMap.php | 74 +++++ app/Support/Auth/Capabilities.php | 12 + app/Support/Auth/WorkspaceRole.php | 11 + app/Support/Rbac/WorkspaceAccessContext.php | 45 +++ app/Support/Workspaces/WorkspaceContext.php | 133 +++++++++ app/Support/Workspaces/WorkspaceResolver.php | 25 ++ bootstrap/app.php | 5 + database/factories/WorkspaceFactory.php | 27 ++ .../factories/WorkspaceMembershipFactory.php | 25 ++ ...6_01_31_230301_create_workspaces_table.php | 31 ++ ...302_create_workspace_memberships_table.php | 33 +++ ...3_add_last_workspace_id_to_users_table.php | 32 +++ ...0304_add_workspace_id_to_tenants_table.php | 47 +++ ...4_add_workspace_id_to_audit_logs_table.php | 164 +++++++++++ ...fill_default_workspace_and_memberships.php | 142 +++++++++ ...49_add_archived_at_to_workspaces_table.php | 30 ++ .../ChooseTenantIsWorkspaceScopedTest.php | 69 +++++ .../ChooseTenantRequiresWorkspaceTest.php | 35 +++ .../TenantSwitcherUrlResolvesTenantTest.php | 37 +++ ...seWorkspaceRedirectsToChooseTenantTest.php | 28 ++ .../CreateWorkspaceCreatesMembershipTest.php | 64 +++++ tests/Pest.php | 27 ++ 40 files changed, 2227 insertions(+) create mode 100644 app/Filament/Pages/ChooseWorkspace.php create mode 100644 app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php create mode 100644 app/Filament/Resources/Workspaces/Pages/EditWorkspace.php create mode 100644 app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php create mode 100644 app/Filament/Resources/Workspaces/WorkspaceResource.php create mode 100644 app/Http/Middleware/EnsureWorkspaceMember.php create mode 100644 app/Http/Middleware/EnsureWorkspaceSelected.php create mode 100644 app/Models/Workspace.php create mode 100644 app/Models/WorkspaceMembership.php create mode 100644 app/Policies/WorkspaceMembershipPolicy.php create mode 100644 app/Policies/WorkspacePolicy.php create mode 100644 app/Services/Audit/WorkspaceAuditLogger.php create mode 100644 app/Services/Auth/WorkspaceCapabilityResolver.php create mode 100644 app/Services/Auth/WorkspaceMembershipManager.php create mode 100644 app/Services/Auth/WorkspaceRoleCapabilityMap.php create mode 100644 app/Support/Auth/WorkspaceRole.php create mode 100644 app/Support/Rbac/WorkspaceAccessContext.php create mode 100644 app/Support/Workspaces/WorkspaceContext.php create mode 100644 app/Support/Workspaces/WorkspaceResolver.php create mode 100644 database/factories/WorkspaceFactory.php create mode 100644 database/factories/WorkspaceMembershipFactory.php create mode 100644 database/migrations/2026_01_31_230301_create_workspaces_table.php create mode 100644 database/migrations/2026_01_31_230302_create_workspace_memberships_table.php create mode 100644 database/migrations/2026_01_31_230303_add_last_workspace_id_to_users_table.php create mode 100644 database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php create mode 100644 database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php create mode 100644 database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php create mode 100644 database/migrations/2026_02_01_085849_add_archived_at_to_workspaces_table.php create mode 100644 tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php create mode 100644 tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php create mode 100644 tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php create mode 100644 tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php create mode 100644 tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php new file mode 100644 index 0000000..10bd3d0 --- /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(ChooseTenant::getUrl()); + } + + /** + * @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(ChooseTenant::getUrl()); + } +} diff --git a/app/Filament/Pages/NoAccess.php b/app/Filament/Pages/NoAccess.php index 07574bf..02c2e64 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(ChooseTenant::getUrl()); + } } diff --git a/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php b/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php new file mode 100644 index 0000000..7b1a167 --- /dev/null +++ b/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php @@ -0,0 +1,35 @@ +user(); + + if (! $user instanceof User) { + return; + } + + WorkspaceMembership::query()->firstOrCreate( + [ + 'workspace_id' => $this->record->getKey(), + 'user_id' => $user->getKey(), + ], + [ + 'role' => 'owner', + ], + ); + + app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request()); + } +} diff --git a/app/Filament/Resources/Workspaces/Pages/EditWorkspace.php b/app/Filament/Resources/Workspaces/Pages/EditWorkspace.php new file mode 100644 index 0000000..3d0763a --- /dev/null +++ b/app/Filament/Resources/Workspaces/Pages/EditWorkspace.php @@ -0,0 +1,11 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('slug') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('slug') + ->searchable() + ->sortable(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWorkspaces::route('/'), + 'create' => Pages\CreateWorkspace::route('/create'), + 'edit' => Pages\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/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/User.php b/app/Models/User.php index a60c6f3..2a70316 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Support\Auth\Capabilities; +use App\Support\Workspaces\WorkspaceContext; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasDefaultTenant; use Filament\Models\Contracts\HasTenants; @@ -141,7 +142,10 @@ public function getTenants(Panel $panel): array|Collection return collect(); } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + return $this->tenants() + ->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId)) ->where('status', 'active') ->orderBy('name') ->get(); @@ -153,6 +157,8 @@ public function getDefaultTenant(Panel $panel): ?Model return null; } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + $tenantId = null; if ($this->tenantPreferencesTableExists()) { @@ -164,6 +170,7 @@ public function getDefaultTenant(Panel $panel): ?Model if ($tenantId !== null) { $tenant = $this->tenants() + ->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId)) ->where('status', 'active') ->whereKey($tenantId) ->first(); @@ -174,6 +181,7 @@ public function getDefaultTenant(Panel $panel): ?Model } return $this->tenants() + ->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId)) ->where('status', 'active') ->orderBy('name') ->first(); 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/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f4fae79..5664e14 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -4,6 +4,7 @@ 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; @@ -37,6 +38,7 @@ public function panel(Panel $panel): Panel ->path('admin') ->login(Login::class) ->authenticatedRoutes(function (Panel $panel): void { + ChooseWorkspace::registerRoutes($panel); ChooseTenant::registerRoutes($panel); NoAccess::registerRoutes($panel); }) @@ -79,6 +81,8 @@ public function panel(Panel $panel): Panel VerifyCsrfToken::class, SubstituteBindings::class, 'ensure-correct-guard:web', + 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, 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..6941777 --- /dev/null +++ b/app/Services/Auth/WorkspaceMembershipManager.php @@ -0,0 +1,272 @@ +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/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index 47bcc50..a17b94b 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -15,6 +15,18 @@ class Capabilities */ private static ?array $all = null; + // 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'; + // Tenants public const TENANT_VIEW = 'tenant.view'; 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 @@ +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..b927f3c --- /dev/null +++ b/app/Support/Workspaces/WorkspaceContext.php @@ -0,0 +1,133 @@ +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..25512e2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,12 +14,17 @@ $middleware->alias([ 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, + 'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class, + 'ensure-workspace-selected' => \App\Http\Middleware\EnsureWorkspaceSelected::class, + 'ensure-filament-tenant-selected' => \App\Support\Middleware\EnsureFilamentTenantSelected::class, ]); $middleware->prependToPriorityList( \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, \App\Http\Middleware\EnsureCorrectGuard::class, ); + + $middleware->redirectGuestsTo('/admin/login'); }) ->withExceptions(function (Exceptions $exceptions): void { // 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/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..75d448b --- /dev/null +++ b/database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php @@ -0,0 +1,47 @@ +foreignId('workspace_id')->nullable(); + + if ($driver !== 'sqlite') { + $column + ->after('id') + ->constrained('workspaces') + ->nullOnDelete(); + } + + $table->index('workspace_id'); + }); + + if ($driver === 'sqlite') { + // SQLite table rebuilds can drop/flatten the partial index defined in + // 2025_12_11_192942_add_is_current_to_tenants.php. Recreate it here. + DB::statement('DROP INDEX IF EXISTS tenants_current_unique'); + DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = 1 AND deleted_at IS NULL'); + } + } + + /** + * 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..0b7334e --- /dev/null +++ b/database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php @@ -0,0 +1,142 @@ +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 {} +}; 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/tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php b/tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php new file mode 100644 index 0000000..514f10c --- /dev/null +++ b/tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php @@ -0,0 +1,69 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'name' => 'Tenant A', + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'name' => 'Tenant B', + 'status' => 'active', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()]) + ->get('/admin/choose-tenant') + ->assertSuccessful() + ->assertSee('Tenant A') + ->assertDontSee('Tenant B'); +}); diff --git a/tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php b/tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php new file mode 100644 index 0000000..5b43f97 --- /dev/null +++ b/tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php @@ -0,0 +1,35 @@ +create([ + 'last_workspace_id' => null, + ]); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->get('/admin/choose-tenant') + ->assertRedirect('/admin/choose-workspace'); +}); diff --git a/tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php b/tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php new file mode 100644 index 0000000..08ab387 --- /dev/null +++ b/tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php @@ -0,0 +1,37 @@ +create(); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'name' => 'Tenant A', + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'name' => 'Tenant B', + 'status' => 'active', + ]); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + createUserWithTenant($tenantB, user: $user, role: 'owner'); + + Filament::setTenant($tenantA, true); + expect(Filament::getTenant()?->is($tenantA))->toBeTrue(); + + $response = $this->actingAs($user) + ->get('/admin/t/'.$tenantB->external_id); + + expect(in_array($response->getStatusCode(), [200, 302], true))->toBeTrue(); + expect(Filament::getTenant())->toBeInstanceOf(Tenant::class); + expect(Filament::getTenant()?->is($tenantB))->toBeTrue(); +}); diff --git a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php new file mode 100644 index 0000000..fae599b --- /dev/null +++ b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php @@ -0,0 +1,28 @@ +create(); + + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(ChooseWorkspace::class) + ->call('selectWorkspace', $workspace->getKey()) + ->assertRedirect('/admin/choose-tenant'); +}); diff --git a/tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php b/tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php new file mode 100644 index 0000000..b20010a --- /dev/null +++ b/tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php @@ -0,0 +1,64 @@ +create(); + + $existingWorkspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $existingWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + app(WorkspaceContext::class)->setCurrentWorkspace($existingWorkspace, $user); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $existingWorkspace->getKey(), + 'status' => 'active', + ]); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(CreateWorkspace::class) + ->fillForm([ + 'name' => 'Acme Workspace', + 'slug' => 'acme-workspace', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $createdWorkspace = Workspace::query() + ->where('slug', 'acme-workspace') + ->first(); + + expect($createdWorkspace)->not->toBeNull(); + + $this->assertDatabaseHas('workspace_memberships', [ + 'workspace_id' => $createdWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); +}); diff --git a/tests/Pest.php b/tests/Pest.php index ebe72a2..9209eb8 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,7 +2,10 @@ use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use App\Services\Graph\GraphClientInterface; +use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\Support\AssertsNoOutboundHttp; use Tests\Support\FailHardGraphClient; @@ -90,6 +93,30 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string $user ??= User::factory()->create(); $tenant ??= Tenant::factory()->create(); + $workspace = null; + + if ($tenant->workspace_id !== null) { + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first(); + } + + if (! $workspace instanceof Workspace) { + $workspace = Workspace::factory()->create(); + + $tenant->forceFill([ + 'workspace_id' => (int) $workspace->getKey(), + ])->save(); + } + + WorkspaceMembership::query()->firstOrCreate([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + ], [ + 'role' => 'owner', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => $role], ]);