feat: workspace foundation + workspace-scoped tenant selection

This commit is contained in:
Ahmed Darrazi 2026-02-02 10:59:10 +01:00
parent 717e2d95a3
commit ea526b255a
40 changed files with 2227 additions and 0 deletions

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
class ChooseWorkspace extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'choose-workspace';
protected static ?string $title = 'Choose workspace';
protected string $view = 'filament.pages.choose-workspace';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
}
/**
* @return Collection<int, Workspace>
*/
public function getWorkspaces(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Workspace::query()->whereRaw('1 = 0')->get();
}
return Workspace::query()
->whereIn('id', function ($query) use ($user): void {
$query->from('workspace_memberships')
->select('workspace_id')
->where('user_id', $user->getKey());
})
->whereNull('archived_at')
->orderBy('name')
->get();
}
public function selectWorkspace(int $workspaceId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! empty($workspace->archived_at)) {
abort(404);
}
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, request());
$this->redirect(ChooseTenant::getUrl());
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$this->redirect(ChooseTenant::getUrl());
}
}

View File

@ -4,6 +4,13 @@
namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class NoAccess extends Page
@ -19,4 +26,60 @@ class NoAccess extends Page
protected static ?string $title = 'No access';
protected string $view = 'filament.pages.no-access';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$this->redirect(ChooseTenant::getUrl());
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
class CreateWorkspace extends CreateRecord
{
protected static string $resource = WorkspaceResource::class;
protected function afterCreate(): void
{
$user = auth()->user();
if (! $user instanceof User) {
return;
}
WorkspaceMembership::query()->firstOrCreate(
[
'workspace_id' => $this->record->getKey(),
'user_id' => $user->getKey(),
],
[
'role' => 'owner',
],
);
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Resources\Pages\EditRecord;
class EditWorkspace extends EditRecord
{
protected static string $resource = WorkspaceResource::class;
}

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\ListRecords;
class ListWorkspaces extends ListRecords
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\Workspaces;
use App\Models\Workspace;
use BackedEnum;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
class WorkspaceResource extends Resource
{
protected static ?string $model = Workspace::class;
protected static bool $isScopedToTenant = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('slug')
->searchable()
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListWorkspaces::route('/'),
'create' => Pages\CreateWorkspace::route('/create'),
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceMember
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
$workspaceParam = $request->route()?->parameter('workspace');
$workspace = $workspaceParam instanceof Workspace
? $workspaceParam
: (is_scalar($workspaceParam)
? app(WorkspaceResolver::class)->resolve((string) $workspaceParam)
: null);
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, $request);
return $next($request);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceSelected
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$routeName = $request->route()?->getName();
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
return $next($request);
}
$path = '/'.ltrim($request->path(), '/');
if (str_starts_with($path, '/admin/t/')) {
return $next($request);
}
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user, $request);
if ($workspace !== null) {
return $next($request);
}
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
? $membershipQuery
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->exists()
: $membershipQuery->exists();
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
return new HttpResponse('', 302, ['Location' => $target]);
}
}

View File

@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
@ -170,6 +171,11 @@ public function memberships(): HasMany
return $this->hasMany(TenantMembership::class);
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function roleMappings(): HasMany
{
return $this->hasMany(TenantRoleMapping::class);

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasDefaultTenant;
use Filament\Models\Contracts\HasTenants;
@ -141,7 +142,10 @@ public function getTenants(Panel $panel): array|Collection
return collect();
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
return $this->tenants()
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
->where('status', 'active')
->orderBy('name')
->get();
@ -153,6 +157,8 @@ public function getDefaultTenant(Panel $panel): ?Model
return null;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
$tenantId = null;
if ($this->tenantPreferencesTableExists()) {
@ -164,6 +170,7 @@ public function getDefaultTenant(Panel $panel): ?Model
if ($tenantId !== null) {
$tenant = $this->tenants()
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
->where('status', 'active')
->whereKey($tenantId)
->first();
@ -174,6 +181,7 @@ public function getDefaultTenant(Panel $panel): ?Model
}
return $this->tenants()
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
->where('status', 'active')
->orderBy('name')
->first();

43
app/Models/Workspace.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Workspace extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceFactory> */
use HasFactory;
protected $guarded = [];
/**
* @return HasMany<WorkspaceMembership, $this>
*/
public function memberships(): HasMany
{
return $this->hasMany(WorkspaceMembership::class);
}
/**
* @return BelongsToMany<User, $this>
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'workspace_memberships')
->using(WorkspaceMembership::class)
->withPivot(['id', 'role'])
->withTimestamps();
}
/**
* @return HasMany<Tenant, $this>
*/
public function tenants(): HasMany
{
return $this->hasMany(Tenant::class);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorkspaceMembership extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceMembershipFactory> */
use HasFactory;
protected $guarded = [];
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
class WorkspaceMembershipPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, WorkspaceMembership $workspaceMembership): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_VIEW);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, WorkspaceMembership $workspaceMembership): bool
{
if ($this->isLastOwner($workspaceMembership)) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, WorkspaceMembership $workspaceMembership): bool
{
if ($this->isLastOwner($workspaceMembership)) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, WorkspaceMembership $workspaceMembership): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, WorkspaceMembership $workspaceMembership): bool
{
return false;
}
public function manageForWorkspace(User $user, Workspace $workspace): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
private function isLastOwner(WorkspaceMembership $membership): bool
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return false;
}
$ownerCount = WorkspaceMembership::query()
->where('workspace_id', $membership->workspace_id)
->where('role', WorkspaceRole::Owner->value)
->count();
return $ownerCount <= 1;
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
class WorkspacePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Workspace $workspace): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Workspace $workspace): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Workspace $workspace): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Workspace $workspace): bool
{
return false;
}
}

View File

@ -4,6 +4,7 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard;
@ -37,6 +38,7 @@ public function panel(Panel $panel): Panel
->path('admin')
->login(Login::class)
->authenticatedRoutes(function (Panel $panel): void {
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel);
})
@ -79,6 +81,8 @@ public function panel(Panel $panel): Panel
VerifyCsrfToken::class,
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\Audit;
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use Carbon\CarbonImmutable;
class WorkspaceAuditLogger
{
public function log(
Workspace $workspace,
string $action,
array $context = [],
?User $actor = null,
string $status = 'success',
?string $resourceType = null,
?string $resourceId = null,
): AuditLog {
$metadata = $context['metadata'] ?? [];
unset($context['metadata']);
return AuditLog::create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => $actor?->getKey(),
'actor_email' => $actor?->email,
'actor_name' => $actor?->name,
'action' => $action,
'resource_type' => $resourceType,
'resource_id' => $resourceId,
'status' => $status,
'metadata' => $metadata + $context,
'recorded_at' => CarbonImmutable::now(),
]);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use Illuminate\Support\Facades\Log;
/**
* Workspace Capability Resolver
*
* Resolves user memberships and capabilities for a given workspace.
* Caches results per request to avoid N+1 queries.
*/
class WorkspaceCapabilityResolver
{
private array $resolvedMemberships = [];
private array $loggedDenials = [];
public function getRole(User $user, Workspace $workspace): ?WorkspaceRole
{
$membership = $this->getMembership($user, $workspace);
if ($membership === null) {
return null;
}
return WorkspaceRole::tryFrom($membership['role']);
}
public function can(User $user, Workspace $workspace, string $capability): bool
{
if (! Capabilities::isKnown($capability)) {
throw new \InvalidArgumentException("Unknown capability: {$capability}");
}
$role = $this->getRole($user, $workspace);
if ($role === null) {
$this->logDenial($user, $workspace, $capability);
return false;
}
$allowed = WorkspaceRoleCapabilityMap::hasCapability($role, $capability);
if (! $allowed) {
$this->logDenial($user, $workspace, $capability);
}
return $allowed;
}
public function isMember(User $user, Workspace $workspace): bool
{
return $this->getMembership($user, $workspace) !== null;
}
public function clearCache(): void
{
$this->resolvedMemberships = [];
}
private function logDenial(User $user, Workspace $workspace, string $capability): void
{
$key = implode(':', [(string) $user->getKey(), (string) $workspace->getKey(), $capability]);
if (isset($this->loggedDenials[$key])) {
return;
}
$this->loggedDenials[$key] = true;
Log::warning('rbac.workspace.denied', [
'capability' => $capability,
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $user->getKey(),
]);
}
private function getMembership(User $user, Workspace $workspace): ?array
{
$cacheKey = "workspace_membership_{$user->id}_{$workspace->id}";
if (! isset($this->resolvedMemberships[$cacheKey])) {
$membership = WorkspaceMembership::query()
->where('user_id', $user->id)
->where('workspace_id', $workspace->id)
->first(['role']);
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
}
return $this->resolvedMemberships[$cacheKey];
}
}

View File

@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use DomainException;
use Illuminate\Support\Facades\DB;
class WorkspaceMembershipManager
{
public function __construct(public WorkspaceAuditLogger $auditLogger) {}
public function addMember(
Workspace $workspace,
User $actor,
User $member,
string $role,
string $source = 'manual',
): WorkspaceMembership {
$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();
if ($existing) {
if ($existing->role !== $role) {
$fromRole = (string) $existing->role;
$existing->forceFill([
'role' => $role,
])->save();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRoleChange->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'from_role' => $fromRole,
'to_role' => $role,
'source' => $source,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
return $existing->refresh();
}
$membership = WorkspaceMembership::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $member->getKey(),
'role' => $role,
]);
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipAdd->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'role' => $role,
'source' => $source,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
return $membership;
});
}
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
{
$this->assertValidRole($newRole);
$this->assertActorCanManage($actor, $workspace);
try {
return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership {
$membership->refresh();
if ($membership->workspace_id !== (int) $workspace->getKey()) {
throw new DomainException('Membership belongs to a different workspace.');
}
$oldRole = (string) $membership->role;
if ($oldRole === $newRole) {
return $membership;
}
$this->guardLastOwnerDemotion($workspace, $membership, $newRole);
$membership->forceFill([
'role' => $newRole,
])->save();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRoleChange->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => $oldRole,
'to_role' => $newRole,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
return $membership->refresh();
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => (string) $membership->role,
'attempted_to_role' => $newRole,
],
],
actor: $actor,
status: 'blocked',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
throw $exception;
}
}
public function removeMember(Workspace $workspace, User $actor, WorkspaceMembership $membership): void
{
$this->assertActorCanManage($actor, $workspace);
try {
DB::transaction(function () use ($workspace, $actor, $membership): void {
$membership->refresh();
if ($membership->workspace_id !== (int) $workspace->getKey()) {
throw new DomainException('Membership belongs to a different workspace.');
}
$this->guardLastOwnerRemoval($workspace, $membership);
$memberUserId = (int) $membership->user_id;
$oldRole = (string) $membership->role;
$membership->delete();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRemove->value,
context: [
'metadata' => [
'member_user_id' => $memberUserId,
'role' => $oldRole,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'role' => (string) $membership->role,
'attempted_action' => 'remove',
],
],
actor: $actor,
status: 'blocked',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
throw $exception;
}
}
private function assertActorCanManage(User $actor, Workspace $workspace): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) {
throw new DomainException('Forbidden.');
}
}
private function assertValidRole(string $role): void
{
$valid = array_map(
static fn (WorkspaceRole $workspaceRole): string => $workspaceRole->value,
WorkspaceRole::cases(),
);
if (! in_array($role, $valid, true)) {
throw new DomainException('Invalid role.');
}
}
private function guardLastOwnerDemotion(Workspace $workspace, WorkspaceMembership $membership, string $newRole): void
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return;
}
if ($newRole === WorkspaceRole::Owner->value) {
return;
}
$owners = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('role', WorkspaceRole::Owner->value)
->count();
if ($owners <= 1) {
throw new DomainException('You cannot demote the last remaining owner.');
}
}
private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership $membership): void
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return;
}
$owners = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('role', WorkspaceRole::Owner->value)
->count();
if ($owners <= 1) {
throw new DomainException('You cannot remove the last remaining owner.');
}
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Services\Auth;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
/**
* Workspace Role to Capability Mapping (Single Source of Truth)
*
* This class defines which capabilities each workspace role has.
* All capability strings MUST be references from the Capabilities registry.
*/
class WorkspaceRoleCapabilityMap
{
/**
* @var array<string, array<int, string>>
*/
private static array $roleCapabilities = [
WorkspaceRole::Owner->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MANAGE,
Capabilities::WORKSPACE_ARCHIVE,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
],
WorkspaceRole::Manager->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
],
WorkspaceRole::Operator->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
],
WorkspaceRole::Readonly->value => [
Capabilities::WORKSPACE_VIEW,
],
];
/**
* @return array<string>
*/
public static function getCapabilities(WorkspaceRole|string $role): array
{
$roleValue = $role instanceof WorkspaceRole ? $role->value : $role;
return self::$roleCapabilities[$roleValue] ?? [];
}
/**
* @return array<string>
*/
public static function rolesWithCapability(string $capability): array
{
$roles = [];
foreach (self::$roleCapabilities as $role => $capabilities) {
if (in_array($capability, $capabilities, true)) {
$roles[] = $role;
}
}
return $roles;
}
public static function hasCapability(WorkspaceRole|string $role, string $capability): bool
{
return in_array($capability, self::getCapabilities($role), true);
}
}

View File

@ -15,6 +15,18 @@ class Capabilities
*/
private static ?array $all = null;
// Workspaces
public const WORKSPACE_VIEW = 'workspace.view';
public const WORKSPACE_MANAGE = 'workspace.manage';
public const WORKSPACE_ARCHIVE = 'workspace.archive';
// Workspace memberships
public const WORKSPACE_MEMBERSHIP_VIEW = 'workspace_membership.view';
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
// Tenants
public const TENANT_VIEW = 'tenant.view';

View File

@ -0,0 +1,11 @@
<?php
namespace App\Support\Auth;
enum WorkspaceRole: string
{
case Owner = 'owner';
case Manager = 'manager';
case Operator = 'operator';
case Readonly = 'readonly';
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\User;
use App\Models\Workspace;
/**
* DTO representing the access context for a workspace-scoped UI action.
*/
final readonly class WorkspaceAccessContext
{
public function __construct(
public ?User $user,
public ?Workspace $workspace,
public bool $isMember,
public bool $hasCapability,
) {}
/**
* Non-members should receive 404 (deny-as-not-found).
*/
public function shouldDenyAsNotFound(): bool
{
return ! $this->isMember;
}
/**
* Members without capability should receive 403 (forbidden).
*/
public function shouldDenyAsForbidden(): bool
{
return $this->isMember && ! $this->hasCapability;
}
/**
* User is authorized to perform the action.
*/
public function isAuthorized(): bool
{
return $this->isMember && $this->hasCapability;
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Support\Workspaces;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Http\Request;
final class WorkspaceContext
{
public const SESSION_KEY = 'current_workspace_id';
public function __construct(private WorkspaceResolver $resolver) {}
public function currentWorkspaceId(?Request $request = null): ?int
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$id = $session->get(self::SESSION_KEY);
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
}
public function currentWorkspace(?Request $request = null): ?Workspace
{
$id = $this->currentWorkspaceId($request);
if (! $id) {
return null;
}
$workspace = Workspace::query()->whereKey($id)->first();
if (! $workspace) {
return null;
}
if (! $this->isWorkspaceSelectable($workspace)) {
return null;
}
return $workspace;
}
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
if ($user !== null) {
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
}
}
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$session->forget(self::SESSION_KEY);
if ($user !== null && $user->last_workspace_id !== null) {
$user->forceFill(['last_workspace_id' => null])->save();
}
}
public function resolveInitialWorkspaceFor(User $user, ?Request $request = null): ?Workspace
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$currentId = $this->currentWorkspaceId($request);
if ($currentId) {
$current = Workspace::query()->whereKey($currentId)->first();
if (! $current instanceof Workspace || ! $this->isWorkspaceSelectable($current) || ! $this->isMember($user, $current)) {
$session->forget(self::SESSION_KEY);
if ((int) $user->last_workspace_id === (int) $currentId) {
$user->forceFill(['last_workspace_id' => null])->save();
}
} else {
return $current;
}
}
if ($user->last_workspace_id !== null) {
$workspace = Workspace::query()->whereKey($user->last_workspace_id)->first();
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) {
$user->forceFill(['last_workspace_id' => null])->save();
} else {
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
return $workspace;
}
}
$memberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->with('workspace')
->get();
$selectableWorkspaces = $memberships
->map(fn (WorkspaceMembership $membership) => $membership->workspace)
->filter(fn (?Workspace $workspace) => $workspace instanceof Workspace && $this->isWorkspaceSelectable($workspace))
->values();
if ($selectableWorkspaces->count() === 1) {
/** @var Workspace $workspace */
$workspace = $selectableWorkspaces->first();
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
return $workspace;
}
return null;
}
public function isMember(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
}
private function isWorkspaceSelectable(Workspace $workspace): bool
{
return empty($workspace->archived_at);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Workspaces;
use App\Models\Workspace;
final class WorkspaceResolver
{
public function resolve(string $value): ?Workspace
{
$workspace = Workspace::query()
->where('slug', $value)
->first();
if ($workspace !== null) {
return $workspace;
}
if (! ctype_digit($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->first();
}
}

View File

@ -14,12 +14,17 @@
$middleware->alias([
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,
'ensure-workspace-selected' => \App\Http\Middleware\EnsureWorkspaceSelected::class,
'ensure-filament-tenant-selected' => \App\Support\Middleware\EnsureFilamentTenantSelected::class,
]);
$middleware->prependToPriorityList(
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
\App\Http\Middleware\EnsureCorrectGuard::class,
);
$middleware->redirectGuestsTo('/admin/login');
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Workspace>
*/
class WorkspaceFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = $this->faker->company();
return [
'name' => $name,
'slug' => Str::slug($name).'-'.$this->faker->unique()->randomNumber(5),
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceMembership>
*/
class WorkspaceMembershipFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workspace_id' => \App\Models\Workspace::factory(),
'user_id' => \App\Models\User::factory(),
'role' => 'operator',
];
}
}

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('workspaces', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->nullable()->unique();
$table->timestamps();
$table->index('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspaces');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('workspace_memberships', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['owner', 'manager', 'operator', 'readonly']);
$table->timestamps();
$table->unique(['workspace_id', 'user_id']);
$table->index(['workspace_id', 'role']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspace_memberships');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('last_workspace_id')
->nullable()
->after('remember_token')
->constrained('workspaces')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('last_workspace_id');
});
}
};

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = DB::getDriverName();
Schema::table('tenants', function (Blueprint $table) use ($driver): void {
$column = $table->foreignId('workspace_id')->nullable();
if ($driver !== 'sqlite') {
$column
->after('id')
->constrained('workspaces')
->nullOnDelete();
}
$table->index('workspace_id');
});
if ($driver === 'sqlite') {
// SQLite table rebuilds can drop/flatten the partial index defined in
// 2025_12_11_192942_add_is_current_to_tenants.php. Recreate it here.
DB::statement('DROP INDEX IF EXISTS tenants_current_unique');
DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = 1 AND deleted_at IS NULL');
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropConstrainedForeignId('workspace_id');
});
}
};

View File

@ -0,0 +1,164 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'sqlite') {
Schema::disableForeignKeyConstraints();
Schema::rename('audit_logs', 'audit_logs_old');
foreach ([
'audit_logs_tenant_id_action_index',
'audit_logs_tenant_id_resource_type_index',
'audit_logs_recorded_at_index',
] as $indexName) {
DB::statement("DROP INDEX IF EXISTS {$indexName}");
}
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_email')->nullable();
$table->string('actor_name')->nullable();
$table->string('action');
$table->string('resource_type')->nullable();
$table->string('resource_id')->nullable();
$table->string('status')->default('success');
$table->json('metadata')->nullable();
$table->timestamp('recorded_at')->useCurrent();
$table->timestamps();
$table->index(['tenant_id', 'action']);
$table->index(['tenant_id', 'resource_type']);
$table->index(['workspace_id', 'action']);
$table->index(['workspace_id', 'resource_type']);
$table->index('recorded_at');
});
DB::table('audit_logs_old')->orderBy('id')->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
DB::table('audit_logs')->insert([
'id' => $row->id,
'tenant_id' => $row->tenant_id,
'workspace_id' => null,
'actor_id' => $row->actor_id,
'actor_email' => $row->actor_email,
'actor_name' => $row->actor_name,
'action' => $row->action,
'resource_type' => $row->resource_type,
'resource_id' => $row->resource_id,
'status' => $row->status,
'metadata' => $row->metadata,
'recorded_at' => $row->recorded_at,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
]);
}
}, 'id');
Schema::drop('audit_logs_old');
Schema::enableForeignKeyConstraints();
return;
}
DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id DROP NOT NULL');
Schema::table('audit_logs', function (Blueprint $table) {
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete()->after('tenant_id');
$table->index(['workspace_id', 'action']);
$table->index(['workspace_id', 'resource_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'sqlite') {
Schema::disableForeignKeyConstraints();
Schema::rename('audit_logs', 'audit_logs_new');
foreach ([
'audit_logs_tenant_id_action_index',
'audit_logs_tenant_id_resource_type_index',
'audit_logs_recorded_at_index',
'audit_logs_workspace_id_action_index',
'audit_logs_workspace_id_resource_type_index',
] as $indexName) {
DB::statement("DROP INDEX IF EXISTS {$indexName}");
}
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_email')->nullable();
$table->string('actor_name')->nullable();
$table->string('action');
$table->string('resource_type')->nullable();
$table->string('resource_id')->nullable();
$table->string('status')->default('success');
$table->json('metadata')->nullable();
$table->timestamp('recorded_at')->useCurrent();
$table->timestamps();
$table->index(['tenant_id', 'action']);
$table->index(['tenant_id', 'resource_type']);
$table->index('recorded_at');
});
DB::table('audit_logs_new')->whereNotNull('tenant_id')->orderBy('id')->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
DB::table('audit_logs')->insert([
'id' => $row->id,
'tenant_id' => $row->tenant_id,
'actor_id' => $row->actor_id,
'actor_email' => $row->actor_email,
'actor_name' => $row->actor_name,
'action' => $row->action,
'resource_type' => $row->resource_type,
'resource_id' => $row->resource_id,
'status' => $row->status,
'metadata' => $row->metadata,
'recorded_at' => $row->recorded_at,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
]);
}
}, 'id');
Schema::drop('audit_logs_new');
Schema::enableForeignKeyConstraints();
return;
}
Schema::table('audit_logs', function (Blueprint $table) {
$table->dropConstrainedForeignId('workspace_id');
$table->dropIndex(['workspace_id', 'action']);
$table->dropIndex(['workspace_id', 'resource_type']);
});
DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id SET NOT NULL');
}
};

View File

@ -0,0 +1,142 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('workspaces')) {
return;
}
$now = now();
$defaultWorkspaceId = DB::table('workspaces')
->where('slug', 'default')
->value('id');
if (! $defaultWorkspaceId) {
$defaultWorkspaceId = DB::table('workspaces')->insertGetId([
'name' => 'Default Workspace',
'slug' => 'default',
'created_at' => $now,
'updated_at' => $now,
]);
}
if (Schema::hasTable('tenants') && Schema::hasColumn('tenants', 'workspace_id')) {
DB::table('tenants')
->whereNull('workspace_id')
->update([
'workspace_id' => $defaultWorkspaceId,
'updated_at' => $now,
]);
}
if (! Schema::hasTable('workspace_memberships')) {
return;
}
$roleRankToRole = [
4 => 'owner',
3 => 'manager',
2 => 'operator',
1 => 'readonly',
];
$userRoleRanks = collect();
if (Schema::hasTable('tenant_memberships')) {
$userRoleRanks = DB::table('tenant_memberships')
->select([
'user_id',
DB::raw("MAX(CASE role WHEN 'owner' THEN 4 WHEN 'manager' THEN 3 WHEN 'operator' THEN 2 WHEN 'readonly' THEN 1 ELSE 0 END) AS role_rank"),
])
->groupBy('user_id')
->get();
}
$rows = [];
$userIds = [];
foreach ($userRoleRanks as $row) {
$role = $roleRankToRole[(int) $row->role_rank] ?? null;
if (! $role) {
continue;
}
$rows[] = [
'workspace_id' => $defaultWorkspaceId,
'user_id' => $row->user_id,
'role' => $role,
'created_at' => $now,
'updated_at' => $now,
];
$userIds[] = $row->user_id;
}
if (empty($rows) && Schema::hasTable('users')) {
$firstUserId = DB::table('users')->orderBy('id')->value('id');
if ($firstUserId) {
$rows[] = [
'workspace_id' => $defaultWorkspaceId,
'user_id' => $firstUserId,
'role' => 'owner',
'created_at' => $now,
'updated_at' => $now,
];
$userIds[] = $firstUserId;
}
}
if (! empty($rows)) {
foreach (array_chunk($rows, 500) as $chunk) {
DB::table('workspace_memberships')->insertOrIgnore($chunk);
}
}
$ownerCount = DB::table('workspace_memberships')
->where('workspace_id', $defaultWorkspaceId)
->where('role', 'owner')
->count();
if ($ownerCount === 0) {
$firstMembershipId = DB::table('workspace_memberships')
->where('workspace_id', $defaultWorkspaceId)
->orderBy('id')
->value('id');
if ($firstMembershipId) {
DB::table('workspace_memberships')
->where('id', $firstMembershipId)
->update([
'role' => 'owner',
'updated_at' => $now,
]);
}
}
if (Schema::hasTable('users') && ! empty($userIds) && Schema::hasColumn('users', 'last_workspace_id')) {
DB::table('users')
->whereIn('id', array_unique($userIds))
->whereNull('last_workspace_id')
->update([
'last_workspace_id' => $defaultWorkspaceId,
'updated_at' => $now,
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void {}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->timestamp('archived_at')->nullable()->after('slug');
$table->index('archived_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->dropIndex(['archived_at']);
$table->dropColumn('archived_at');
});
}
};

View File

@ -0,0 +1,69 @@
<?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('only shows tenants from the currently selected workspace on choose-tenant', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
$tenantA = Tenant::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'name' => 'Tenant A',
'status' => 'active',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'name' => 'Tenant B',
'status' => 'active',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
TenantMembership::query()->create([
'tenant_id' => $tenantA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
TenantMembership::query()->create([
'tenant_id' => $tenantB->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
->get('/admin/choose-tenant')
->assertSuccessful()
->assertSee('Tenant A')
->assertDontSee('Tenant B');
});

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('redirects choose-tenant to choose-workspace when workspace is not selected', function (): void {
$user = User::factory()->create([
'last_workspace_id' => null,
]);
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get('/admin/choose-tenant')
->assertRedirect('/admin/choose-workspace');
});

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\Workspace;
use Filament\Facades\Filament;
it('switches tenant when visiting the tenant menu URL', function (): void {
$workspace = Workspace::factory()->create();
$tenantA = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'name' => 'Tenant A',
'status' => 'active',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'name' => 'Tenant B',
'status' => 'active',
]);
[$user] = createUserWithTenant($tenantA, role: 'owner');
createUserWithTenant($tenantB, user: $user, role: 'owner');
Filament::setTenant($tenantA, true);
expect(Filament::getTenant()?->is($tenantA))->toBeTrue();
$response = $this->actingAs($user)
->get('/admin/t/'.$tenantB->external_id);
expect(in_array($response->getStatusCode(), [200, 302], true))->toBeTrue();
expect(Filament::getTenant())->toBeInstanceOf(Tenant::class);
expect(Filament::getTenant()?->is($tenantB))->toBeTrue();
});

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\ChooseWorkspace;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('redirects to choose-tenant after selecting a workspace', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
Livewire::actingAs($user)
->test(ChooseWorkspace::class)
->call('selectWorkspace', $workspace->getKey())
->assertRedirect('/admin/choose-tenant');
});

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\Workspaces\Pages\CreateWorkspace;
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 Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('creates an owner membership when a workspace is created via the Workspaces resource', function () {
$user = User::factory()->create();
$existingWorkspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $existingWorkspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($existingWorkspace, $user);
$tenant = Tenant::factory()->create([
'workspace_id' => $existingWorkspace->getKey(),
'status' => 'active',
]);
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(CreateWorkspace::class)
->fillForm([
'name' => 'Acme Workspace',
'slug' => 'acme-workspace',
])
->call('create')
->assertHasNoFormErrors();
$createdWorkspace = Workspace::query()
->where('slug', 'acme-workspace')
->first();
expect($createdWorkspace)->not->toBeNull();
$this->assertDatabaseHas('workspace_memberships', [
'workspace_id' => $createdWorkspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
});

View File

@ -2,7 +2,10 @@
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Graph\GraphClientInterface;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Support\AssertsNoOutboundHttp;
use Tests\Support\FailHardGraphClient;
@ -90,6 +93,30 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string
$user ??= User::factory()->create();
$tenant ??= Tenant::factory()->create();
$workspace = null;
if ($tenant->workspace_id !== null) {
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
}
if (! $workspace instanceof Workspace) {
$workspace = Workspace::factory()->create();
$tenant->forceFill([
'workspace_id' => (int) $workspace->getKey(),
])->save();
}
WorkspaceMembership::query()->firstOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
], [
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => $role],
]);