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 <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #87
This commit is contained in:
parent
38d9826f5e
commit
5f9e6fb04a
@ -25,11 +25,22 @@ public function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('user.name')
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Columns\TextColumn::make('user.email')
|
Tables\Columns\TextColumn::make('user_domain')
|
||||||
->label(__('Email'))
|
->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),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('role')
|
Tables\Columns\TextColumn::make('role')
|
||||||
->badge()
|
->badge()
|
||||||
@ -49,7 +60,13 @@ public function table(Table $table): Table
|
|||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->required()
|
->required()
|
||||||
->searchable()
|
->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')
|
Forms\Components\Select::make('role')
|
||||||
->label(__('Role'))
|
->label(__('Role'))
|
||||||
->required()
|
->required()
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
use App\Services\Auth\WorkspaceMembershipManager;
|
use App\Services\Auth\WorkspaceMembershipManager;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\WorkspaceRole;
|
use App\Support\Auth\WorkspaceRole;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -26,11 +26,22 @@ public function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('user.name')
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Columns\TextColumn::make('user.email')
|
Tables\Columns\TextColumn::make('user_domain')
|
||||||
->label(__('Email'))
|
->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),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('role')
|
Tables\Columns\TextColumn::make('role')
|
||||||
->badge()
|
->badge()
|
||||||
@ -38,7 +49,7 @@ public function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
UiEnforcement::forTableAction(
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
Action::make('add_member')
|
Action::make('add_member')
|
||||||
->label(__('Add member'))
|
->label(__('Add member'))
|
||||||
->icon('heroicon-o-plus')
|
->icon('heroicon-o-plus')
|
||||||
@ -47,7 +58,13 @@ public function table(Table $table): Table
|
|||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->required()
|
->required()
|
||||||
->searchable()
|
->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')
|
Forms\Components\Select::make('role')
|
||||||
->label(__('Role'))
|
->label(__('Role'))
|
||||||
->required()
|
->required()
|
||||||
@ -105,7 +122,7 @@ public function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
UiEnforcement::forTableAction(
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
Action::make('change_role')
|
Action::make('change_role')
|
||||||
->label(__('Change role'))
|
->label(__('Change role'))
|
||||||
->icon('heroicon-o-pencil')
|
->icon('heroicon-o-pencil')
|
||||||
@ -159,7 +176,7 @@ public function table(Table $table): Table
|
|||||||
->tooltip('You do not have permission to manage workspace memberships.')
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::forTableAction(
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
Action::make('remove')
|
Action::make('remove')
|
||||||
->label(__('Remove'))
|
->label(__('Remove'))
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
|||||||
230
app/Support/Rbac/WorkspaceUiEnforcement.php
Normal file
230
app/Support/Rbac/WorkspaceUiEnforcement.php
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Rbac;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||||
|
use Closure;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central workspace-scoped RBAC UI Enforcement Helper for Filament Actions.
|
||||||
|
*
|
||||||
|
* Mirrors the tenant-scoped UiEnforcement semantics, but uses WorkspaceMembership
|
||||||
|
* + WorkspaceCapabilityResolver.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Non-member → hidden UI + 404 server-side
|
||||||
|
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
|
||||||
|
* - Member with capability → enabled
|
||||||
|
*/
|
||||||
|
final class WorkspaceUiEnforcement
|
||||||
|
{
|
||||||
|
private Action $action;
|
||||||
|
|
||||||
|
private bool $requireMembership = true;
|
||||||
|
|
||||||
|
private ?string $capability = null;
|
||||||
|
|
||||||
|
private bool $isDestructive = false;
|
||||||
|
|
||||||
|
private ?string $customTooltip = null;
|
||||||
|
|
||||||
|
private Model|Closure|null $record = null;
|
||||||
|
|
||||||
|
private function __construct(Action $action)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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] 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).
|
- [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
|
## Validation
|
||||||
- [x] T900 Run Pint on dirty files.
|
- [x] T900 Run Pint on dirty files.
|
||||||
- [x] T910 Run targeted Pest tests.
|
- [x] T910 Run targeted Pest tests.
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\Pages\EditWorkspace;
|
||||||
|
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('Workspace memberships relation manager UI enforcement', function () {
|
||||||
|
it('shows membership actions as enabled for owner members', function () {
|
||||||
|
$workspace = Workspace::factory()->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.';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user