From 5f9e6fb04a776391596d1e8089aee0a17693cc9b Mon Sep 17 00:00:00 2001 From: ahmido Date: Mon, 2 Feb 2026 23:54:22 +0000 Subject: [PATCH] feat: workspace-first managed tenants + RBAC membership UI fixes (072) (#87) Implements spec 072 (workspace-first managed tenants enforcement) and follow-up RBAC fixes. Highlights: - Workspace-scoped managed tenants landing and enforcement for tenant routes. - Workspace membership management UI fixed to use workspace capabilities. - Membership tables now show user email + domain for clearer identification. Tests: - Targeted Pest tests for routing/enforcement and RBAC UI enforcement. - Pint ran on dirty files. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/87 --- .../TenantMembershipsRelationManager.php | 25 +- .../WorkspaceMembershipsRelationManager.php | 33 ++- app/Support/Rbac/WorkspaceUiEnforcement.php | 230 ++++++++++++++++++ .../tasks.md | 4 + ...rshipsRelationManagerUiEnforcementTest.php | 80 ++++++ 5 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 app/Support/Rbac/WorkspaceUiEnforcement.php create mode 100644 tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php index 78ddf84..19ef4ab 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php @@ -25,11 +25,22 @@ public function table(Table $table): Table return $table ->modifyQueryUsing(fn (Builder $query) => $query->with('user')) ->columns([ - Tables\Columns\TextColumn::make('user.name') + Tables\Columns\TextColumn::make('user.email') ->label(__('User')) ->searchable(), - Tables\Columns\TextColumn::make('user.email') - ->label(__('Email')) + Tables\Columns\TextColumn::make('user_domain') + ->label(__('Domain')) + ->getStateUsing(function (TenantMembership $record): ?string { + $email = $record->user?->email; + + if (! is_string($email) || $email === '' || ! str_contains($email, '@')) { + return null; + } + + return (string) str($email)->after('@')->lower(); + }), + Tables\Columns\TextColumn::make('user.name') + ->label(__('Name')) ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('role') ->badge() @@ -49,7 +60,13 @@ public function table(Table $table): Table ->label(__('User')) ->required() ->searchable() - ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + ->options(fn () => User::query() + ->orderBy('email') + ->get(['id', 'name', 'email']) + ->mapWithKeys(fn (User $user): array => [ + (string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)), + ]) + ->all()), Forms\Components\Select::make('role') ->label(__('Role')) ->required() diff --git a/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php b/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php index 4be9fc1..2664006 100644 --- a/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php +++ b/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php @@ -8,7 +8,7 @@ use App\Services\Auth\WorkspaceMembershipManager; use App\Support\Auth\Capabilities; use App\Support\Auth\WorkspaceRole; -use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\WorkspaceUiEnforcement; use Filament\Actions\Action; use Filament\Forms; use Filament\Notifications\Notification; @@ -26,11 +26,22 @@ public function table(Table $table): Table return $table ->modifyQueryUsing(fn (Builder $query) => $query->with('user')) ->columns([ - Tables\Columns\TextColumn::make('user.name') + Tables\Columns\TextColumn::make('user.email') ->label(__('User')) ->searchable(), - Tables\Columns\TextColumn::make('user.email') - ->label(__('Email')) + Tables\Columns\TextColumn::make('user_domain') + ->label(__('Domain')) + ->getStateUsing(function (WorkspaceMembership $record): ?string { + $email = $record->user?->email; + + if (! is_string($email) || $email === '' || ! str_contains($email, '@')) { + return null; + } + + return (string) str($email)->after('@')->lower(); + }), + Tables\Columns\TextColumn::make('user.name') + ->label(__('Name')) ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('role') ->badge() @@ -38,7 +49,7 @@ public function table(Table $table): Table Tables\Columns\TextColumn::make('created_at')->since(), ]) ->headerActions([ - UiEnforcement::forTableAction( + WorkspaceUiEnforcement::forTableAction( Action::make('add_member') ->label(__('Add member')) ->icon('heroicon-o-plus') @@ -47,7 +58,13 @@ public function table(Table $table): Table ->label(__('User')) ->required() ->searchable() - ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + ->options(fn () => User::query() + ->orderBy('email') + ->get(['id', 'name', 'email']) + ->mapWithKeys(fn (User $user): array => [ + (string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)), + ]) + ->all()), Forms\Components\Select::make('role') ->label(__('Role')) ->required() @@ -105,7 +122,7 @@ public function table(Table $table): Table ->apply(), ]) ->actions([ - UiEnforcement::forTableAction( + WorkspaceUiEnforcement::forTableAction( Action::make('change_role') ->label(__('Change role')) ->icon('heroicon-o-pencil') @@ -159,7 +176,7 @@ public function table(Table $table): Table ->tooltip('You do not have permission to manage workspace memberships.') ->apply(), - UiEnforcement::forTableAction( + WorkspaceUiEnforcement::forTableAction( Action::make('remove') ->label(__('Remove')) ->color('danger') diff --git a/app/Support/Rbac/WorkspaceUiEnforcement.php b/app/Support/Rbac/WorkspaceUiEnforcement.php new file mode 100644 index 0000000..2696de2 --- /dev/null +++ b/app/Support/Rbac/WorkspaceUiEnforcement.php @@ -0,0 +1,230 @@ +action = $action; + } + + /** + * Create enforcement for a table action. + * + * @param Action $action The Filament action to wrap + * @param Model|Closure $record The owner record or a closure that returns it + */ + public static function forTableAction(Action $action, Model|Closure $record): self + { + $instance = new self($action); + $instance->record = $record; + + return $instance; + } + + public function requireMembership(bool $require = true): self + { + $this->requireMembership = $require; + + return $this; + } + + /** + * @throws \InvalidArgumentException If capability is not in the canonical registry + */ + public function requireCapability(string $capability): self + { + if (! Capabilities::isKnown($capability)) { + throw new \InvalidArgumentException( + "Unknown capability: {$capability}. Use constants from ".Capabilities::class + ); + } + + $this->capability = $capability; + + return $this; + } + + public function destructive(): self + { + $this->isDestructive = true; + + return $this; + } + + public function tooltip(string $message): self + { + $this->customTooltip = $message; + + return $this; + } + + public function apply(): Action + { + $this->applyVisibility(); + $this->applyDisabledState(); + $this->applyDestructiveConfirmation(); + $this->applyServerSideGuard(); + + return $this->action; + } + + private function applyVisibility(): void + { + if (! $this->requireMembership) { + return; + } + + $this->action->visible(function (?Model $record = null): bool { + $context = $this->resolveContextWithRecord($record); + + return $context->isMember; + }); + } + + private function applyDisabledState(): void + { + if ($this->capability === null) { + return; + } + + $tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission(); + + $this->action->disabled(function (?Model $record = null): bool { + $context = $this->resolveContextWithRecord($record); + + if (! $context->isMember) { + return true; + } + + return ! $context->hasCapability; + }); + + $this->action->tooltip(function (?Model $record = null) use ($tooltip): ?string { + $context = $this->resolveContextWithRecord($record); + + if ($context->isMember && ! $context->hasCapability) { + return $tooltip; + } + + return null; + }); + } + + private function applyDestructiveConfirmation(): void + { + if (! $this->isDestructive) { + return; + } + + $this->action->requiresConfirmation(); + $this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE); + $this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION); + } + + private function applyServerSideGuard(): void + { + $this->action->before(function (?Model $record = null): void { + $context = $this->resolveContextWithRecord($record); + + if ($context->shouldDenyAsNotFound()) { + abort(404); + } + + if ($context->shouldDenyAsForbidden()) { + abort(403); + } + }); + } + + private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext + { + $user = auth()->user(); + $workspace = $this->resolveWorkspaceWithRecord($record); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return new WorkspaceAccessContext( + user: null, + workspace: null, + isMember: false, + hasCapability: false, + ); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + $isMember = $resolver->isMember($user, $workspace); + + $hasCapability = true; + if ($this->capability !== null && $isMember) { + $hasCapability = $resolver->can($user, $workspace, $this->capability); + } + + return new WorkspaceAccessContext( + user: $user, + workspace: $workspace, + isMember: $isMember, + hasCapability: $hasCapability, + ); + } + + private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace + { + if ($record instanceof Workspace) { + return $record; + } + + if ($this->record !== null) { + try { + $resolved = $this->record instanceof Closure + ? ($this->record)() + : $this->record; + + if ($resolved instanceof Workspace) { + return $resolved; + } + } catch (Throwable) { + return null; + } + } + + return null; + } +} diff --git a/specs/072-managed-tenants-workspace-enforcement/tasks.md b/specs/072-managed-tenants-workspace-enforcement/tasks.md index 1ee239c..74860ea 100644 --- a/specs/072-managed-tenants-workspace-enforcement/tasks.md +++ b/specs/072-managed-tenants-workspace-enforcement/tasks.md @@ -29,6 +29,10 @@ ## Security hardening (owners / audit / recovery) - [x] T270 Audit every blocked last-owner attempt with `workspace_membership.last_owner_blocked` + required metadata. - [x] T280 Optional: break-glass recovery flow to re-assign a workspace owner (fully audited). +## Follow-up bugfix +- [x] T300 Fix Workspaces → Memberships UI enforcement to use workspace capabilities (not tenant capabilities). +- [x] T310 Add regression tests for WorkspaceMemberships relation manager action enable/disable. + ## Validation - [x] T900 Run Pint on dirty files. - [x] T910 Run targeted Pest tests. diff --git a/tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php new file mode 100644 index 0000000..facf7d5 --- /dev/null +++ b/tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php @@ -0,0 +1,80 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $otherUser = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $otherUser->getKey(), + 'role' => 'readonly', + ]); + + Livewire::test(WorkspaceMembershipsRelationManager::class, [ + 'ownerRecord' => $workspace, + 'pageClass' => EditWorkspace::class, + ]) + ->assertTableActionVisible('add_member') + ->assertTableActionEnabled('add_member') + ->assertTableActionVisible('change_role') + ->assertTableActionEnabled('change_role') + ->assertTableActionVisible('remove') + ->assertTableActionEnabled('remove'); + }); + + it('shows membership actions as visible but disabled for readonly members', function () { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($user); + + Livewire::test(WorkspaceMembershipsRelationManager::class, [ + 'ownerRecord' => $workspace, + 'pageClass' => EditWorkspace::class, + ]) + ->assertTableActionVisible('add_member') + ->assertTableActionDisabled('add_member') + ->assertTableActionExists('add_member', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage workspace memberships.'; + }) + ->assertTableActionVisible('change_role') + ->assertTableActionDisabled('change_role') + ->assertTableActionExists('change_role', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage workspace memberships.'; + }) + ->assertTableActionVisible('remove') + ->assertTableActionDisabled('remove') + ->assertTableActionExists('remove', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage workspace memberships.'; + }); + }); +});