feat: workspace-first managed tenants + RBAC membership UI fixes (072) #87

Merged
ahmido merged 13 commits from feat/072-managed-tenants-workspace-enforcement into dev 2026-02-02 23:54:23 +00:00
48 changed files with 1753 additions and 127 deletions
Showing only changes of commit 35e14c1075 - Show all commits

View File

@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
->firstOrFail();
}
return Tenant::current();
return Tenant::currentOrFail();
}
}

View File

@ -138,7 +138,7 @@ private function resolveTenants()
}
try {
return collect([Tenant::current()]);
return collect([Tenant::currentOrFail()]);
} catch (RuntimeException) {
return collect();
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Pages;
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
@ -72,6 +73,11 @@ public function selectTenant(int $tenantId): void
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
public function canRegisterTenant(): bool
{
return RegisterTenantPage::canView();
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {

View File

@ -9,6 +9,7 @@
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -100,7 +101,7 @@ public function selectWorkspace(int $workspaceId): void
$context->setCurrentWorkspace($workspace, $user, request());
$this->redirect(ChooseTenant::getUrl());
$this->redirect($this->redirectAfterWorkspaceSelected($user));
}
/**
@ -132,6 +133,32 @@ public function createWorkspace(array $data): void
->success()
->send();
$this->redirect(ChooseTenant::getUrl());
$this->redirect($this->redirectAfterWorkspaceSelected($user));
}
private function redirectAfterWorkspaceSelected(User $user): string
{
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
$tenants = $tenants instanceof Collection ? $tenants : collect($tenants);
if ($tenants->isEmpty()) {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$role = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->value('role');
if (in_array($role, ['owner', 'manager'], true)) {
return route('filament.admin.tenant.registration');
}
}
return ChooseTenant::getUrl();
}
return ChooseTenant::getUrl();
}
}

View File

@ -4,9 +4,11 @@
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Forms;
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
use Filament\Schemas\Schema;
@ -27,6 +29,20 @@ public static function canView(): bool
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$canRegisterInWorkspace = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->whereIn('role', ['owner', 'manager'])
->exists();
if ($canRegisterInWorkspace) {
return true;
}
}
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
if ($tenantIds->isEmpty()) {
@ -95,6 +111,12 @@ protected function handleRegistration(array $data): Model
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$data['workspace_id'] = $workspaceId;
}
$tenant = Tenant::create($data);
$user = auth()->user();

View File

@ -938,7 +938,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->where('tenant_id', $tenantId)
@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array
public static function assignTenant(array $data): array
{
$data['tenant_id'] = Tenant::current()->getKey();
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
return $data;
}

View File

@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')

View File

@ -894,7 +894,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))

View File

@ -815,7 +815,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))

View File

@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
@ -219,7 +219,7 @@ public static function getWizardSteps(): array
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))

View File

@ -9,6 +9,7 @@
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver;
@ -30,6 +31,7 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -70,7 +72,21 @@ public static function canCreate(): bool
return false;
}
return static::userCanManageAnyTenant($user);
if (static::userCanManageAnyTenant($user)) {
return true;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->whereIn('role', ['owner', 'manager'])
->exists();
}
public static function canEdit(Model $record): bool
@ -179,8 +195,15 @@ public static function getEloquentQuery(): Builder
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$tenantIds = $user->tenants()
->withTrashed()
->where('workspace_id', $workspaceId)
->pluck('tenants.id');
return parent::getEloquentQuery()

View File

@ -4,12 +4,28 @@
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord
{
protected static string $resource = TenantResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$data['workspace_id'] = $workspaceId;
}
return $data;
}
protected function afterCreate(): void
{
$user = auth()->user();

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewWorkspace extends ViewRecord
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@ -0,0 +1,204 @@
<?php
namespace App\Filament\Resources\Workspaces\RelationManagers;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceMembershipManager;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WorkspaceMembershipsRelationManager extends RelationManager
{
protected static string $relationship = 'memberships';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([
Tables\Columns\TextColumn::make('user.name')
->label(__('User'))
->searchable(),
Tables\Columns\TextColumn::make('user.email')
->label(__('Email'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->headerActions([
UiEnforcement::forTableAction(
Action::make('add_member')
->label(__('Add member'))
->icon('heroicon-o-plus')
->form([
Forms\Components\Select::make('user_id')
->label(__('User'))
->required()
->searchable()
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
WorkspaceRole::Owner->value => __('Owner'),
WorkspaceRole::Manager->value => __('Manager'),
WorkspaceRole::Operator->value => __('Operator'),
WorkspaceRole::Readonly->value => __('Readonly'),
]),
])
->action(function (array $data, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title(__('User not found'))->danger()->send();
return;
}
try {
$manager->addMember(
workspace: $workspace,
actor: $actor,
member: $member,
role: (string) $data['role'],
source: 'manual',
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to add member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->apply(),
])
->actions([
UiEnforcement::forTableAction(
Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
WorkspaceRole::Owner->value => __('Owner'),
WorkspaceRole::Manager->value => __('Manager'),
WorkspaceRole::Operator->value => __('Operator'),
WorkspaceRole::Readonly->value => __('Readonly'),
]),
])
->action(function (WorkspaceMembership $record, array $data, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->changeRole(
workspace: $workspace,
actor: $actor,
membership: $record,
newRole: (string) $data['role'],
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to change role'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->apply(),
UiEnforcement::forTableAction(
Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to remove member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->destructive()
->apply(),
])
->bulkActions([]);
}
}

View File

@ -2,8 +2,10 @@
namespace App\Filament\Resources\Workspaces;
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
use App\Models\Workspace;
use BackedEnum;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
@ -13,10 +15,14 @@
class WorkspaceResource extends Resource
{
protected static bool $isDiscovered = false;
protected static ?string $model = Workspace::class;
protected static bool $isScopedToTenant = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
@ -47,7 +53,8 @@ public static function table(Table $table): Table
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
Actions\ViewAction::make(),
Actions\EditAction::make(),
]);
}
@ -56,7 +63,15 @@ public static function getPages(): array
return [
'index' => Pages\ListWorkspaces::route('/'),
'create' => Pages\CreateWorkspace::route('/create'),
'view' => Pages\ViewWorkspace::route('/{record}'),
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
WorkspaceMembershipsRelationManager::class,
];
}
}

View File

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages;
use App\Models\PlatformUser;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\BreakGlassSession;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Auth\WorkspaceRole;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class RepairWorkspaceOwners extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static ?string $navigationLabel = 'Repair workspace owners';
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
protected string $view = 'filament.system.pages.repair-workspace-owners';
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$breakGlass = app(BreakGlassSession::class);
return [
Action::make('assign_owner')
->label('Assign owner (break-glass)')
->color('danger')
->requiresConfirmation()
->modalHeading('Assign workspace owner')
->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.')
->form([
Select::make('workspace_id')
->label('Workspace')
->required()
->searchable()
->getSearchResultsUsing(function (string $search): array {
return Workspace::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->value('name');
}),
Select::make('target_user_id')
->label('User')
->required()
->searchable()
->getSearchResultsUsing(function (string $search): array {
return User::query()
->where('email', 'like', "%{$search}%")
->orderBy('email')
->limit(25)
->pluck('email', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return User::query()->whereKey((int) $value)->value('email');
}),
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void {
$platformUser = auth('platform')->user();
if (! $platformUser instanceof PlatformUser) {
abort(403);
}
if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
abort(403);
}
if (! $breakGlass->isActive()) {
abort(403);
}
$workspaceId = (int) ($data['workspace_id'] ?? 0);
$targetUserId = (int) ($data['target_user_id'] ?? 0);
$reason = (string) ($data['reason'] ?? '');
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
$membership = WorkspaceMembership::query()->firstOrNew([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $targetUser->getKey(),
]);
$fromRole = $membership->exists ? (string) $membership->role : null;
$membership->forceFill([
'role' => WorkspaceRole::Owner->value,
])->save();
$auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value,
context: [
'metadata' => [
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $platformUser->getKey(),
'target_user_id' => (int) $targetUser->getKey(),
'attempted_role' => WorkspaceRole::Owner->value,
'from_role' => $fromRole,
'reason' => trim($reason),
'source' => 'break_glass',
],
],
actor: null,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
actorId: (int) $platformUser->getKey(),
actorEmail: $platformUser->email,
actorName: $platformUser->name,
);
Notification::make()
->title('Owner assigned')
->success()
->send();
})
->disabled(fn (): bool => ! $breakGlass->isActive()),
];
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
final class SelectTenantController
{
public function __invoke(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if ($workspaceId === null) {
return redirect()->to('/admin/choose-workspace');
}
$validated = $request->validate([
'tenant_id' => ['required', 'integer'],
]);
$tenant = Tenant::query()
->where('status', 'active')
->where('workspace_id', $workspaceId)
->whereKey($validated['tenant_id'])
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
return;
}
if (! Schema::hasTable('user_tenant_preferences')) {
return;
}
UserTenantPreference::query()->updateOrCreate(
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
['last_used_at' => now()]
);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class SwitchWorkspaceController
{
public function __invoke(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$validated = $request->validate([
'workspace_id' => ['required', 'integer'],
]);
$workspace = Workspace::query()->whereKey($validated['workspace_id'])->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);
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
$tenants = $tenants instanceof \Illuminate\Database\Eloquent\Collection ? $tenants : collect($tenants);
if ($tenants->isEmpty()) {
if (RegisterTenantPage::canView()) {
return redirect()->route('filament.admin.tenant.registration');
}
return redirect()->to(ChooseTenant::getUrl());
}
return redirect()->to(ChooseTenant::getUrl());
}
}

View File

@ -32,6 +32,10 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
if (str_starts_with($path, '/admin/workspaces')) {
return $next($request);
}
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
return $next($request);
}

View File

@ -61,7 +61,7 @@ public static function externalIdShort(?string $externalId): string
public function table(Table $table): Table
{
$backupSet = BackupSet::query()->find($this->backupSetId);
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
$tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey();
$existingPolicyIds = $backupSet
? $backupSet->items()->pluck('policy_id')->filter()->all()
: [];

View File

@ -117,7 +117,7 @@ public function makeCurrent(): void
$this->forceFill(['is_current' => true]);
}
public static function current(): self
public static function current(): ?self
{
$filamentTenant = Filament::getTenant();
@ -146,6 +146,13 @@ public static function current(): self
->where('is_current', true)
->first();
return $tenant;
}
public static function currentOrFail(): self
{
$tenant = static::current();
if (! $tenant) {
throw new RuntimeException('No current tenant selected.');
}

View File

@ -26,7 +26,11 @@ public function boot(): void
$resolver = app(CapabilityResolver::class);
$defineTenantCapability = function (string $capability) use ($resolver): void {
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($resolver, $capability): bool {
if (! $tenant instanceof Tenant) {
return false;
}
return $resolver->can($user, $tenant, $capability);
});
};

View File

@ -8,6 +8,7 @@
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
@ -15,6 +16,7 @@
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
@ -38,6 +40,7 @@ public function panel(Panel $panel): Panel
->path('admin')
->login(Login::class)
->authenticatedRoutes(function (Panel $panel): void {
WorkspaceResource::registerRoutes($panel);
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel);
@ -50,10 +53,21 @@ public function panel(Panel $panel): Panel
->colors([
'primary' => Color::Amber,
])
->navigationItems([
NavigationItem::make('Workspaces')
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(10),
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()
)
->renderHook(
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
fn () => view('filament.partials.workspace-switcher')->render()
)
->renderHook(
PanelsRenderHook::BODY_END,
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)

View File

@ -19,6 +19,9 @@ public function log(
string $status = 'success',
?string $resourceType = null,
?string $resourceId = null,
?int $actorId = null,
?string $actorEmail = null,
?string $actorName = null,
): AuditLog {
$metadata = $context['metadata'] ?? [];
unset($context['metadata']);
@ -26,9 +29,9 @@ public function log(
return AuditLog::create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => $actor?->getKey(),
'actor_email' => $actor?->email,
'actor_name' => $actor?->name,
'actor_id' => $actor?->getKey() ?? $actorId,
'actor_email' => $actor?->email ?? $actorEmail,
'actor_name' => $actor?->name ?? $actorName,
'action' => $action,
'resource_type' => $resourceType,
'resource_id' => $resourceId,

View File

@ -28,65 +28,82 @@ public function addMember(
$this->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();
try {
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;
if ($existing) {
if ($existing->role !== $role) {
$fromRole = (string) $existing->role;
$existing->forceFill([
'role' => $role,
])->save();
$this->guardLastOwnerDemotion($workspace, $existing, $role);
$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,
$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(),
);
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
return $existing->refresh();
}
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;
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
$this->auditLastOwnerBlocked(
workspace: $workspace,
actor: $actor,
targetUserId: (int) $member->getKey(),
attemptedRole: $role,
currentRole: WorkspaceRole::Owner->value,
attemptedAction: 'role_change',
);
}
$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;
});
throw $exception;
}
}
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
@ -134,20 +151,13 @@ public function changeRole(Workspace $workspace, User $actor, WorkspaceMembershi
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
$this->auditLogger->log(
$this->auditLastOwnerBlocked(
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(),
targetUserId: (int) $membership->user_id,
attemptedRole: $newRole,
currentRole: (string) $membership->role,
attemptedAction: 'role_change',
);
}
@ -191,20 +201,13 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
$this->auditLogger->log(
$this->auditLastOwnerBlocked(
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(),
targetUserId: (int) $membership->user_id,
attemptedRole: (string) $membership->role,
currentRole: (string) $membership->role,
attemptedAction: 'remove',
);
}
@ -269,4 +272,32 @@ private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership
throw new DomainException('You cannot remove the last remaining owner.');
}
}
private function auditLastOwnerBlocked(
Workspace $workspace,
User $actor,
int $targetUserId,
string $attemptedRole,
string $currentRole,
string $attemptedAction,
): void {
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'target_user_id' => $targetUserId,
'attempted_role' => $attemptedRole,
'current_role' => $currentRole,
'attempted_action' => $attemptedAction,
],
],
actor: $actor,
status: 'blocked',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
}

View File

@ -6,6 +6,12 @@
enum AuditActionId: string
{
case WorkspaceMembershipAdd = 'workspace_membership.add';
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
case WorkspaceMembershipRemove = 'workspace_membership.remove';
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner';
case TenantMembershipAdd = 'tenant_membership.add';
case TenantMembershipRoleChange = 'tenant_membership.role_change';
case TenantMembershipRemove = 'tenant_membership.remove';

View File

@ -10,6 +10,8 @@
use Closure;
use Filament\Facades\Filament;
use Filament\Models\Contracts\HasTenants;
use Filament\Navigation\NavigationBuilder;
use Filament\Navigation\NavigationItem;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@ -20,6 +22,8 @@ class EnsureFilamentTenantSelected
*/
public function handle(Request $request, Closure $next): Response
{
$panel = Filament::getCurrentOrDefaultPanel();
if ($request->route()?->hasParameter('tenant')) {
$user = $request->user();
@ -31,8 +35,6 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
$panel = Filament::getCurrentOrDefaultPanel();
if (! $panel->hasTenancy()) {
return $next($request);
}
@ -72,54 +74,105 @@ public function handle(Request $request, Closure $next): Response
Filament::setTenant($tenant, true);
$this->configureNavigationForRequest($panel);
return $next($request);
}
if (filled(Filament::getTenant())) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
$tenant = null;
try {
$tenant = Tenant::current();
} catch (\RuntimeException) {
$tenant = null;
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if ($workspaceId !== null) {
$tenant = $user->tenants()
->where('workspace_id', $workspaceId)
->where('status', 'active')
->first();
if (! $tenant) {
$tenant = $user->tenants()
->where('workspace_id', $workspaceId)
->first();
}
if (! $tenant) {
$tenant = $user->tenants()
->withTrashed()
->where('workspace_id', $workspaceId)
->first();
}
}
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
$tenant = null;
if (! $tenant) {
try {
$tenant = Tenant::current();
} catch (\RuntimeException) {
$tenant = null;
}
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
$tenant = null;
}
}
if (! $tenant) {
$tenant = $user->tenants()
->whereNull('deleted_at')
->where('status', 'active')
->first();
}
if (! $tenant) {
$tenant = $user->tenants()
->whereNull('deleted_at')
->first();
$tenant = $user->tenants()->first();
}
if (! $tenant) {
$tenant = $user->tenants()
->withTrashed()
->first();
$tenant = $user->tenants()->withTrashed()->first();
}
if ($tenant) {
Filament::setTenant($tenant, true);
}
$this->configureNavigationForRequest($panel);
return $next($request);
}
private function configureNavigationForRequest(\Filament\Panel $panel): void
{
if (! $panel->hasTenancy()) {
return;
}
if (filled(Filament::getTenant())) {
$panel->navigation(true);
return;
}
$panel->navigation(function (): NavigationBuilder {
return app(NavigationBuilder::class)
->item(
NavigationItem::make('Workspaces')
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(10),
);
});
}
}

View File

@ -88,10 +88,6 @@ public function resolveInitialWorkspaceFor(User $user, ?Request $request = null)
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;
}
}

View File

@ -11,25 +11,62 @@
@if ($tenants->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
No tenants are available for your account.
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
@if ($this->canRegisterTenant())
Register a tenant for this workspace, or switch workspaces.
@else
Switch workspaces, or contact an administrator.
@endif
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
@if ($this->canRegisterTenant())
<x-filament::button
type="button"
color="primary"
tag="a"
href="{{ route('filament.admin.tenant.registration') }}"
>
Register tenant
</x-filament::button>
@endif
<x-filament::button
type="button"
color="gray"
tag="a"
href="{{ route('filament.admin.pages.choose-workspace') }}"
>
Change workspace
</x-filament::button>
</div>
</div>
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tenants as $tenant)
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div class="flex flex-col gap-3">
<div
wire:key="tenant-{{ $tenant->id }}"
x-data
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
class="cursor-pointer rounded-lg border border-gray-200 p-4 dark:border-gray-800"
>
<form x-ref="form" method="POST" action="{{ route('admin.select-tenant') }}" class="flex flex-col gap-3">
@csrf
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->id }}" />
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $tenant->name }}
</div>
<x-filament::button
type="button"
type="submit"
color="primary"
wire:click="selectTenant({{ (int) $tenant->id }})"
class="w-full"
>
Continue
</x-filament::button>
</div>
</form>
</div>
@endforeach
</div>

View File

@ -7,6 +7,14 @@
@php
$workspaces = $this->getWorkspaces();
$user = auth()->user();
$recommendedWorkspaceId = $user instanceof \App\Models\User ? (int) ($user->last_workspace_id ?? 0) : 0;
if ($recommendedWorkspaceId > 0) {
[$recommended, $other] = $workspaces->partition(fn ($workspace) => (int) $workspace->id === $recommendedWorkspaceId);
$workspaces = $recommended->concat($other)->values();
}
@endphp
@if ($workspaces->isEmpty())
@ -17,20 +25,42 @@
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($workspaces as $workspace)
<div wire:key="workspace-{{ $workspace->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div class="flex flex-col gap-3">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $workspace->name }}
@php
$isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId;
@endphp
<div
wire:key="workspace-{{ $workspace->id }}"
x-data
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
class="cursor-pointer rounded-lg border p-4 dark:border-gray-800 {{ $isRecommended ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30' : 'border-gray-200' }}"
>
<form x-ref="form" method="POST" action="{{ route('admin.switch-workspace') }}" class="flex flex-col gap-3">
@csrf
<input type="hidden" name="workspace_id" value="{{ (int) $workspace->id }}" />
<div class="flex flex-col gap-2">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $workspace->name }}
</div>
@if ($isRecommended)
<div>
<x-filament::badge color="warning" size="sm">
Last used
</x-filament::badge>
</div>
@endif
</div>
<x-filament::button
type="button"
type="submit"
color="primary"
wire:click="selectWorkspace({{ (int) $workspace->id }})"
class="w-full"
>
Continue
</x-filament::button>
</div>
</form>
</div>
@endforeach
</div>

View File

@ -0,0 +1,47 @@
@php
/** @var \App\Support\Workspaces\WorkspaceContext $workspaceContext */
$workspaceContext = app(\App\Support\Workspaces\WorkspaceContext::class);
$user = auth()->user();
$currentWorkspaceId = $workspaceContext->currentWorkspaceId(request());
$workspaces = collect();
if ($user instanceof \App\Models\User) {
$workspaces = \App\Models\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();
}
@endphp
@if ($workspaces->isNotEmpty())
<x-filament::dropdown.list>
<div class="px-3 py-2">
<form method="POST" action="{{ route('admin.switch-workspace') }}" class="space-y-2">
@csrf
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Workspace</div>
<select
name="workspace_id"
class="fi-input fi-select w-full"
x-data
x-on:change="$el.form.submit()"
>
@foreach ($workspaces as $workspace)
<option value="{{ $workspace->getKey() }}" {{ (int) $workspace->getKey() === (int) $currentWorkspaceId ? 'selected' : '' }}>
{{ $workspace->name }}
</option>
@endforeach
</select>
<div class="text-xs text-gray-500 dark:text-gray-400">Switch workspace</div>
</form>
</div>
</x-filament::dropdown.list>
@endif

View File

@ -0,0 +1,13 @@
<x-filament-panels::page>
<div class="space-y-6">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm text-gray-700 dark:text-gray-200">
<p class="font-medium">Purpose</p>
<p class="mt-1">
This page exists to recover from broken workspace ownership state (e.g. a workspace with zero owners due to manual DB edits).
Actions here require break-glass mode and are fully audited.
</p>
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -4,6 +4,8 @@
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\SelectTenantController;
use App\Http\Controllers\SwitchWorkspaceController;
use App\Http\Controllers\TenantOnboardingController;
use App\Models\Workspace;
use App\Support\Middleware\DenyNonMemberTenantAccess;
@ -25,6 +27,30 @@
Route::get('/admin/consent/start', TenantOnboardingController::class)
->name('admin.consent.start');
// Panel root override: keep the app's workspace-first flow.
// Avoid Filament's tenancy root redirect which otherwise sends users to /admin/register-tenant
// when no default tenant can be resolved.
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->get('/admin', function (Request $request) {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if ($workspaceId === null) {
return redirect()->to('/admin/choose-workspace');
}
return redirect()->to('/admin/choose-tenant');
})
->name('admin.home');
// Fallback route: Filament's layout generates this URL when tenancy registration is enabled.
// In this app, package route registration may not always define it early enough, which breaks
// rendering on tenant-scoped routes.
@ -92,6 +118,14 @@
})
->name('admin.legacy.onboarding');
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
->name('admin.switch-workspace');
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
->post('/admin/select-tenant', SelectTenantController::class)
->name('admin.select-tenant');
Route::bind('workspace', function (string $value): Workspace {
/** @var WorkspaceResolver $resolver */
$resolver = app(WorkspaceResolver::class);

View File

@ -10,11 +10,13 @@ ## Approach
2. Move Managed Tenants list/onboarding UX to workspace-scoped routes.
3. Make `/admin/managed-tenants/*` legacy-only (redirect to the correct workspace-scoped URL).
4. Enforce workspace/tenant consistency for all `/admin/t/{tenant}` routes (deny-as-not-found on mismatch).
5. Ensure the panel UX makes the active workspace visible and switchable from the topbar.
## Key decisions
- **Workspace is not Filament tenancy**; it remains session + middleware.
- Hard enforcement is implemented in middleware that runs on tenant-scoped routes.
- Prefer redirects over removing routes immediately, to avoid breaking deep links, but ensure they are no longer primary UX.
- Default tenant selection must respect the current workspace context to avoid cross-workspace tenant URLs.
## Files (expected)
- `routes/web.php`

View File

@ -12,6 +12,7 @@ ## Mental model (source of truth)
## Goals
- Workspace becomes a real, enforced context for all tenant-scoped pages.
- Keep Filament tenancy URL space unchanged: `/admin/t/{tenant_external_id}/...`.
- Keep Workspaces UI tenantless: `/admin/workspaces/*` (never under `/admin/t/{tenant}/...`).
- Introduce / use a workspace-scoped landing space for portfolio UX: `/admin/w/{workspace}/...`.
- Eliminate or redirect legacy unscoped Managed Tenants routes under `/admin/managed-tenants/*`.

View File

@ -14,7 +14,23 @@ ## Core
- [x] T110 Make unscoped `/admin/managed-tenants/*` legacy-only (redirect to workspace-scoped URLs).
- [x] T120 Implement hard enforcement: tenant routes require workspace context and tenant.workspace_id match.
- [x] T130 Ensure `/admin/choose-tenant` requires selected workspace.
- [x] T140 Move Workspaces UI out of tenant routing (serve at `/admin/workspaces/*`, not `/admin/t/{tenant}/workspaces`).
## UX follow-ups
- [x] T200 Ensure default tenant selection respects current workspace context.
- [x] T210 Add a workspace switcher in the user menu (link to Choose Workspace).
- [x] T220 Add regression tests for workspace switcher + tenant selection.
- [x] T230 Ensure `/admin` lands on workspace-first flow (avoid redirecting to tenant registration).
- [x] T240 After choosing a workspace with zero tenants, route into tenant registration (not empty Choose Tenant).
- [x] T250 Allow workspace owners to register the first tenant in a workspace (bootstrap).
## Security hardening (owners / audit / recovery)
- [x] T260 Enforce rule: workspaces can never have 0 owners (block last-owner removal + demotion).
- [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).
## Validation
- [x] T900 Run Pint on dirty files.
- [x] T910 Run targeted Pest tests.
- [x] T920 Run targeted Pest tests for last-owner + recovery flow.

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Dashboard;
use App\Filament\System\Pages\RepairWorkspaceOwners;
use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Auth\WorkspaceRole;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
config()->set('tenantpilot.break_glass.enabled', true);
config()->set('tenantpilot.break_glass.ttl_minutes', 15);
});
it('can assign a workspace owner via break-glass and audits it', function () {
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::USE_BREAK_GLASS,
],
]);
$this->actingAs($platformUser, 'platform');
$workspace = Workspace::factory()->create();
$targetUser = User::factory()->create();
// Ensure the workspace is in a "broken" state: zero owners.
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $targetUser->getKey(),
'role' => WorkspaceRole::Operator->value,
]);
Livewire::test(Dashboard::class)
->callAction('enter_break_glass', data: [
'reason' => 'Recover workspace ownership',
]);
Livewire::test(RepairWorkspaceOwners::class)
->callAction('assign_owner', data: [
'workspace_id' => (int) $workspace->getKey(),
'target_user_id' => (int) $targetUser->getKey(),
'reason' => 'Fix last owner removed via DB edit',
]);
$membership = WorkspaceMembership::query()
->where('workspace_id', $workspace->getKey())
->where('user_id', $targetUser->getKey())
->firstOrFail();
expect($membership->role)->toBe(WorkspaceRole::Owner->value);
$audit = AuditLog::query()
->where('workspace_id', $workspace->getKey())
->where('action', 'workspace_membership.break_glass.assign_owner')
->where('status', 'success')
->latest('id')
->first();
expect($audit)->not->toBeNull();
expect($audit->metadata)->toMatchArray([
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $platformUser->getKey(),
'target_user_id' => (int) $targetUser->getKey(),
'attempted_role' => WorkspaceRole::Owner->value,
'source' => 'break_glass',
]);
});

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceMembershipManager;
use App\Support\Auth\WorkspaceRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('blocks demoting the last remaining workspace owner and audits it', function () {
$workspace = Workspace::factory()->create();
$actor = User::factory()->create();
$target = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $actor->getKey(),
'role' => WorkspaceRole::Manager->value,
]);
$targetMembership = WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $target->getKey(),
'role' => WorkspaceRole::Owner->value,
]);
$manager = app(WorkspaceMembershipManager::class);
expect(fn () => $manager->changeRole($workspace, $actor, $targetMembership, WorkspaceRole::Manager->value))
->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
$targetMembership->refresh();
expect($targetMembership->role)->toBe(WorkspaceRole::Owner->value);
$audit = AuditLog::query()
->where('workspace_id', $workspace->getKey())
->where('action', 'workspace_membership.last_owner_blocked')
->where('status', 'blocked')
->latest('id')
->first();
expect($audit)->not->toBeNull();
expect($audit->metadata)->toMatchArray([
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'target_user_id' => (int) $target->getKey(),
'attempted_role' => WorkspaceRole::Manager->value,
]);
});
it('blocks removing the last remaining workspace owner and audits it', function () {
$workspace = Workspace::factory()->create();
$actor = User::factory()->create();
$target = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $actor->getKey(),
'role' => WorkspaceRole::Manager->value,
]);
$targetMembership = WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $target->getKey(),
'role' => WorkspaceRole::Owner->value,
]);
$manager = app(WorkspaceMembershipManager::class);
expect(fn () => $manager->removeMember($workspace, $actor, $targetMembership))
->toThrow(DomainException::class, 'You cannot remove the last remaining owner.');
expect(WorkspaceMembership::query()->whereKey($targetMembership->getKey())->exists())->toBeTrue();
$audit = AuditLog::query()
->where('workspace_id', $workspace->getKey())
->where('action', 'workspace_membership.last_owner_blocked')
->where('status', 'blocked')
->latest('id')
->first();
expect($audit)->not->toBeNull();
expect($audit->metadata)->toMatchArray([
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'target_user_id' => (int) $target->getKey(),
'attempted_action' => 'remove',
]);
});

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('redirects /admin to choose-tenant when a workspace is selected', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin')
->assertRedirect(route('filament.admin.pages.choose-tenant'));
});

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('does not show the register-tenant CTA for readonly workspace members when there are no tenants', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'readonly',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant')
->assertSuccessful()
->assertSee('No tenants are available')
->assertDontSee('Register tenant');
});
it('shows the register-tenant CTA for owner workspace members when there are no tenants', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant')
->assertSuccessful()
->assertSee('Register tenant');
});

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('highlights and prioritizes the last used workspace on choose-workspace', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
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',
]);
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
$this->actingAs($user)
->get(route('filament.admin.pages.choose-workspace'))
->assertOk()
->assertSee('Last used')
->assertSeeInOrder([
'Workspace B',
'Workspace A',
]);
});

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('redirects to choose-workspace after login when user has multiple workspaces and no workspace is selected', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
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',
]);
$user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save();
$this->actingAs($user)
->get('/admin')
->assertRedirect(route('filament.admin.pages.choose-workspace'));
});

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
it('selects a tenant via POST and persists last-used', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => $workspace->getKey(),
]);
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
$response = $this
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('admin.select-tenant'), ['tenant_id' => (int) $tenant->getKey()]);
$response->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
$user->refresh();
if (Schema::hasColumn('users', 'last_tenant_id')) {
expect($user->last_tenant_id)->toBe($tenant->getKey());
return;
}
if (Schema::hasTable('user_tenant_preferences')) {
$preference = $user->tenantPreferences()
->where('tenant_id', $tenant->getKey())
->first();
expect($preference)->not->toBeNull();
expect($preference?->last_used_at)->not->toBeNull();
}
});

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('does not show tenants from other workspaces on the tenants index', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
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',
]);
$tenantA = Tenant::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'external_id' => '11111111-1111-1111-1111-111111111111',
'tenant_id' => '11111111-1111-1111-1111-111111111111',
'name' => 'Tenant A',
'status' => 'active',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'external_id' => '22222222-2222-2222-2222-222222222222',
'tenant_id' => '22222222-2222-2222-2222-222222222222',
'name' => 'Tenant B',
'status' => 'active',
]);
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(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenantA)))
->assertOk()
->assertSee('Tenant A')
->assertDontSee('Tenant B');
});

View File

@ -0,0 +1,72 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('default Filament tenant selection respects current workspace context', function () {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
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',
]);
$tenantA = Tenant::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'status' => 'active',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'status' => 'active',
]);
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save();
Filament::setTenant(null, true);
$this->actingAs($user)
->get(route('filament.admin.pages.choose-tenant'))
->assertOk();
expect(Filament::getTenant())
->toBeInstanceOf(Tenant::class)
->and(Filament::getTenant()?->getKey())
->toBe($tenantA->getKey());
});
test('user menu renders a workspace switcher when a workspace is selected', function () {
[$user, $tenant] = createUserWithTenant();
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
$this->actingAs($user)
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->assertOk()
->assertSee($workspace->name)
->assertSee('Switch workspace')
->assertSee('name="workspace_id"', escape: false);
});

View File

@ -3,6 +3,8 @@
declare(strict_types=1);
use App\Filament\Pages\ChooseWorkspace;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@ -11,7 +13,7 @@
uses(RefreshDatabase::class);
it('redirects to choose-tenant after selecting a workspace', function (): void {
it('redirects to tenant registration after selecting a workspace with no tenants', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
@ -21,6 +23,36 @@
'role' => 'owner',
]);
Livewire::actingAs($user)
->test(ChooseWorkspace::class)
->call('selectWorkspace', $workspace->getKey())
->assertRedirect(route('filament.admin.tenant.registration'));
});
it('redirects to choose-tenant after selecting a workspace with tenants', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => $workspace->getKey(),
]);
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
Livewire::actingAs($user)
->test(ChooseWorkspace::class)
->call('selectWorkspace', $workspace->getKey())

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('redirects to tenant registration after switching to a workspace with no tenants', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this
->actingAs($user)
->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()])
->assertRedirect(route('filament.admin.tenant.registration'));
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());
});

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('serves the Workspaces UI tenantless at /admin/workspaces', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['slug' => 'acme']);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/workspaces')
->assertOk();
});
it('serves the Workspaces view page tenantless at /admin/workspaces/{record}', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['slug' => 'acme']);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/workspaces/'.(int) $workspace->getKey())
->assertOk();
});
it('does not expose the Workspaces UI under the tenant route prefix', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['slug' => 'acme']);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'external_id' => '11111111-1111-1111-1111-111111111111',
'tenant_id' => '11111111-1111-1111-1111-111111111111',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/t/11111111-1111-1111-1111-111111111111/workspaces')
->assertNotFound();
});