Compare commits

...

13 Commits

Author SHA1 Message Date
Ahmed Darrazi
759c9950c4 merge: dev into feat/072 2026-02-03 00:53:17 +01:00
Ahmed Darrazi
0915efc386 fix: membership UI show email + domain 2026-02-03 00:49:45 +01:00
Ahmed Darrazi
41672c9a79 feat: workspace-first admin landing
Route /admin based on tenant count in current workspace; add managed-tenants landing; keep tenant selection workspace-scoped; update tests.
2026-02-02 23:58:11 +01:00
Ahmed Darrazi
6079ccb766 fix: do not auto-select workspace on login 2026-02-02 19:59:15 +01:00
Ahmed Darrazi
d4e0632557 fix: workspaces nav uses active tenant 2026-02-02 19:56:06 +01:00
Ahmed Darrazi
eb7e6d56f0 fix: workspace nav without tenant param 2026-02-02 19:52:43 +01:00
Ahmed Darrazi
37a5587a45 merge: origin/dev into feat/072-managed-tenants-workspace-enforcement 2026-02-02 19:06:33 +01:00
Ahmed Darrazi
35e14c1075 feat: enforce workspace context + last-owner safeguards 2026-02-02 16:52:32 +01:00
Ahmed Darrazi
29385a88e4 merge: agent session work 2026-02-02 11:00:12 +01:00
Ahmed Darrazi
b60a8cea04 fix: render Filament page header actions on selection pages 2026-02-02 10:59:34 +01:00
Ahmed Darrazi
b2f419bdb2 feat: enforce workspace context for managed tenants (072) 2026-02-02 10:59:24 +01:00
Ahmed Darrazi
ea526b255a feat: workspace foundation + workspace-scoped tenant selection 2026-02-02 10:59:10 +01:00
Ahmed Darrazi
717e2d95a3 spec: add workspace/tenant enforcement specs (070-072) 2026-02-02 10:58:01 +01:00
5 changed files with 360 additions and 12 deletions

View File

@ -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()

View File

@ -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')

View 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;
}
}

View File

@ -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.

View File

@ -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.';
});
});
});