feat: workspace context enforcement (specs 070–072) #85
137
app/Filament/Pages/ChooseWorkspace.php
Normal file
137
app/Filament/Pages/ChooseWorkspace.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
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 Filament\Pages\Page;
|
||||||
|
|
||||||
class NoAccess extends Page
|
class NoAccess extends Page
|
||||||
@ -19,4 +26,60 @@ class NoAccess extends Page
|
|||||||
protected static ?string $title = 'No access';
|
protected static ?string $title = 'No access';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php
Normal file
35
app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/Workspaces/Pages/EditWorkspace.php
Normal file
11
app/Filament/Resources/Workspaces/Pages/EditWorkspace.php
Normal 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;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
Normal file
19
app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Filament/Resources/Workspaces/WorkspaceResource.php
Normal file
62
app/Filament/Resources/Workspaces/WorkspaceResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Middleware/EnsureWorkspaceMember.php
Normal file
51
app/Http/Middleware/EnsureWorkspaceMember.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Middleware/EnsureWorkspaceSelected.php
Normal file
67
app/Http/Middleware/EnsureWorkspaceSelected.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
@ -170,6 +171,11 @@ public function memberships(): HasMany
|
|||||||
return $this->hasMany(TenantMembership::class);
|
return $this->hasMany(TenantMembership::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function roleMappings(): HasMany
|
public function roleMappings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TenantRoleMapping::class);
|
return $this->hasMany(TenantRoleMapping::class);
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Models\Contracts\HasDefaultTenant;
|
use Filament\Models\Contracts\HasDefaultTenant;
|
||||||
use Filament\Models\Contracts\HasTenants;
|
use Filament\Models\Contracts\HasTenants;
|
||||||
@ -141,7 +142,10 @@ public function getTenants(Panel $panel): array|Collection
|
|||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
return $this->tenants()
|
return $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
@ -153,6 +157,8 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
$tenantId = null;
|
$tenantId = null;
|
||||||
|
|
||||||
if ($this->tenantPreferencesTableExists()) {
|
if ($this->tenantPreferencesTableExists()) {
|
||||||
@ -164,6 +170,7 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
|
|
||||||
if ($tenantId !== null) {
|
if ($tenantId !== null) {
|
||||||
$tenant = $this->tenants()
|
$tenant = $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->whereKey($tenantId)
|
->whereKey($tenantId)
|
||||||
->first();
|
->first();
|
||||||
@ -174,6 +181,7 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenants()
|
return $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
43
app/Models/Workspace.php
Normal file
43
app/Models/Workspace.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Models/WorkspaceMembership.php
Normal file
31
app/Models/WorkspaceMembership.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Policies/WorkspaceMembershipPolicy.php
Normal file
108
app/Policies/WorkspaceMembershipPolicy.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Policies/WorkspacePolicy.php
Normal file
74
app/Policies/WorkspacePolicy.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
@ -37,6 +38,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->path('admin')
|
->path('admin')
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
NoAccess::registerRoutes($panel);
|
NoAccess::registerRoutes($panel);
|
||||||
})
|
})
|
||||||
@ -79,6 +81,8 @@ public function panel(Panel $panel): Panel
|
|||||||
VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
|
'ensure-workspace-selected',
|
||||||
|
'ensure-filament-tenant-selected',
|
||||||
DenyNonMemberTenantAccess::class,
|
DenyNonMemberTenantAccess::class,
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
|
|||||||
40
app/Services/Audit/WorkspaceAuditLogger.php
Normal file
40
app/Services/Audit/WorkspaceAuditLogger.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/Services/Auth/WorkspaceCapabilityResolver.php
Normal file
100
app/Services/Auth/WorkspaceCapabilityResolver.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
272
app/Services/Auth/WorkspaceMembershipManager.php
Normal file
272
app/Services/Auth/WorkspaceMembershipManager.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Services/Auth/WorkspaceRoleCapabilityMap.php
Normal file
74
app/Services/Auth/WorkspaceRoleCapabilityMap.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,18 @@ class Capabilities
|
|||||||
*/
|
*/
|
||||||
private static ?array $all = null;
|
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
|
// Tenants
|
||||||
public const TENANT_VIEW = 'tenant.view';
|
public const TENANT_VIEW = 'tenant.view';
|
||||||
|
|
||||||
|
|||||||
11
app/Support/Auth/WorkspaceRole.php
Normal file
11
app/Support/Auth/WorkspaceRole.php
Normal 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';
|
||||||
|
}
|
||||||
125
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
125
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Closure;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Models\Contracts\HasTenants;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureFilamentTenantSelected
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Closure(Request): Response $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($request->route()?->hasParameter('tenant')) {
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof HasTenants) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$panel = Filament::getCurrentOrDefaultPanel();
|
||||||
|
|
||||||
|
if (! $panel->hasTenancy()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantParameter = $request->route()->parameter('tenant');
|
||||||
|
|
||||||
|
$tenant = $panel->getTenant($tenantParameter);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled(Filament::getTenant())) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = null;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
$tenant = $user->tenants()
|
||||||
|
->withTrashed()
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Support/Rbac/WorkspaceAccessContext.php
Normal file
45
app/Support/Rbac/WorkspaceAccessContext.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/Support/Workspaces/WorkspaceContext.php
Normal file
133
app/Support/Workspaces/WorkspaceContext.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Support/Workspaces/WorkspaceResolver.php
Normal file
25
app/Support/Workspaces/WorkspaceResolver.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,12 +14,17 @@
|
|||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
||||||
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::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(
|
$middleware->prependToPriorityList(
|
||||||
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
||||||
\App\Http\Middleware\EnsureCorrectGuard::class,
|
\App\Http\Middleware\EnsureCorrectGuard::class,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$middleware->redirectGuestsTo('/admin/login');
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
27
database/factories/WorkspaceFactory.php
Normal file
27
database/factories/WorkspaceFactory.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
database/factories/WorkspaceMembershipFactory.php
Normal file
25
database/factories/WorkspaceMembershipFactory.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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 {}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,37 +1,39 @@
|
|||||||
<x-filament::section>
|
<x-filament-panels::page>
|
||||||
<div class="flex flex-col gap-4">
|
<x-filament::section>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="flex flex-col gap-4">
|
||||||
Select a tenant to continue.
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
</div>
|
Select a tenant to continue.
|
||||||
|
|
||||||
@php
|
|
||||||
$tenants = $this->getTenants();
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@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>
|
</div>
|
||||||
@else
|
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
@php
|
||||||
@foreach ($tenants as $tenant)
|
$tenants = $this->getTenants();
|
||||||
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
|
@endphp
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
@if ($tenants->isEmpty())
|
||||||
{{ $tenant->name }}
|
<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>
|
||||||
|
@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 class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $tenant->name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
wire:click="selectTenant({{ (int) $tenant->id }})"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-filament::button
|
|
||||||
type="button"
|
|
||||||
color="primary"
|
|
||||||
wire:click="selectTenant({{ (int) $tenant->id }})"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@endforeach
|
||||||
@endforeach
|
</div>
|
||||||
</div>
|
@endif
|
||||||
@endif
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
</x-filament::section>
|
</x-filament-panels::page>
|
||||||
|
|||||||
40
resources/views/filament/pages/choose-workspace.blade.php
Normal file
40
resources/views/filament/pages/choose-workspace.blade.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Select a workspace to continue.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$workspaces = $this->getWorkspaces();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($workspaces->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 active workspaces are available for your account.
|
||||||
|
You can create one using the button above.
|
||||||
|
</div>
|
||||||
|
@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 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
wire:click="selectWorkspace({{ (int) $workspace->id }})"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -1,11 +1,13 @@
|
|||||||
<x-filament::section>
|
<x-filament-panels::page>
|
||||||
<div class="flex flex-col gap-3">
|
<x-filament::section>
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<div class="flex flex-col gap-3">
|
||||||
You don’t have access to any tenants yet.
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
</div>
|
You don’t have access to any tenants yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Ask an administrator to add you to a tenant, then sign in again.
|
Ask an administrator to add you to a tenant, then sign in again.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
</x-filament::section>
|
</x-filament-panels::page>
|
||||||
|
|||||||
100
routes/web.php
100
routes/web.php
@ -1,9 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||||
use App\Http\Controllers\AdminConsentCallbackController;
|
use App\Http\Controllers\AdminConsentCallbackController;
|
||||||
use App\Http\Controllers\Auth\EntraController;
|
use App\Http\Controllers\Auth\EntraController;
|
||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
use App\Http\Controllers\TenantOnboardingController;
|
use App\Http\Controllers\TenantOnboardingController;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceResolver;
|
||||||
|
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
||||||
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
@ -16,6 +25,24 @@
|
|||||||
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
||||||
->name('admin.consent.start');
|
->name('admin.consent.start');
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
Route::middleware([
|
||||||
|
'web',
|
||||||
|
'panel:admin',
|
||||||
|
'ensure-correct-guard:web',
|
||||||
|
DenyNonMemberTenantAccess::class,
|
||||||
|
DisableBladeIconComponents::class,
|
||||||
|
DispatchServingFilamentEvent::class,
|
||||||
|
FilamentAuthenticate::class,
|
||||||
|
'ensure-workspace-selected',
|
||||||
|
])
|
||||||
|
->prefix('/admin')
|
||||||
|
->name('filament.admin.')
|
||||||
|
->get('/register-tenant', RegisterTenant::class)
|
||||||
|
->name('tenant.registration');
|
||||||
|
|
||||||
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
|
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
|
||||||
->name('admin.rbac.start');
|
->name('admin.rbac.start');
|
||||||
|
|
||||||
@ -28,3 +55,76 @@
|
|||||||
Route::get('/auth/entra/callback', [EntraController::class, 'callback'])
|
Route::get('/auth/entra/callback', [EntraController::class, 'callback'])
|
||||||
->middleware('throttle:entra-callback')
|
->middleware('throttle:entra-callback')
|
||||||
->name('auth.entra.callback');
|
->name('auth.entra.callback');
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||||
|
->get('/admin/managed-tenants', function (Request $request) {
|
||||||
|
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return redirect('/admin/choose-workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants');
|
||||||
|
})
|
||||||
|
->name('admin.legacy.managed-tenants.index');
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||||
|
->get('/admin/managed-tenants/onboarding', function (Request $request) {
|
||||||
|
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return redirect('/admin/choose-workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
|
||||||
|
})
|
||||||
|
->name('admin.legacy.managed-tenants.onboarding');
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||||
|
->get('/admin/new', function (Request $request) {
|
||||||
|
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return redirect('/admin/choose-workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding');
|
||||||
|
})
|
||||||
|
->name('admin.legacy.onboarding');
|
||||||
|
|
||||||
|
Route::bind('workspace', function (string $value): Workspace {
|
||||||
|
/** @var WorkspaceResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceResolver::class);
|
||||||
|
|
||||||
|
$workspace = $resolver->resolve($value);
|
||||||
|
|
||||||
|
abort_unless($workspace instanceof Workspace, 404);
|
||||||
|
|
||||||
|
return $workspace;
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'ensure-workspace-member'])
|
||||||
|
->prefix('/admin/w/{workspace}')
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('/', fn () => redirect('/admin/tenants'))
|
||||||
|
->name('admin.workspace.home');
|
||||||
|
|
||||||
|
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
|
||||||
|
|
||||||
|
Route::get('/managed-tenants', fn () => redirect('/admin/tenants'))
|
||||||
|
->name('admin.workspace.managed-tenants.index');
|
||||||
|
|
||||||
|
Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/tenants/create'))
|
||||||
|
->name('admin.workspace.managed-tenants.onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (app()->runningUnitTests()) {
|
||||||
|
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||||
|
->get('/admin/_test/workspace-context', function (Request $request) {
|
||||||
|
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
15
specs/070-workspace-create-membership-fix/plan.md
Normal file
15
specs/070-workspace-create-membership-fix/plan.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Plan — 070 Workspace create: ensure creator membership
|
||||||
|
|
||||||
|
## Tech
|
||||||
|
- Laravel 12
|
||||||
|
- Filament v5 + Livewire v4
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
1. Hook into the Workspaces resource create page lifecycle (CreateRecord) to run post-create logic.
|
||||||
|
2. Create (or ensure) a `workspace_memberships` row for the creator with role `owner`.
|
||||||
|
3. Set the created workspace as the current workspace in `WorkspaceContext`.
|
||||||
|
4. Add a Pest regression test that creates a workspace via the Filament create page and asserts membership exists.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php`
|
||||||
|
- `tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php`
|
||||||
16
specs/070-workspace-create-membership-fix/spec.md
Normal file
16
specs/070-workspace-create-membership-fix/spec.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Spec — 070 Workspace create: ensure creator membership
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Creating a workspace via the Filament **Workspaces** Resource can create the `workspaces` row but not grant the creating user a `workspace_memberships` record. Since workspace listings are membership-scoped, the newly created workspace may not appear for the creator.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- When a workspace is created via the Workspaces resource, the creating user becomes an `owner` member of that workspace.
|
||||||
|
- The created workspace becomes the current workspace context for the user.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- Changing workspace listing scoping rules.
|
||||||
|
- Changing roles/permissions beyond assigning `owner` on creation.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- Creating a workspace via the Filament Workspaces resource results in a `workspace_memberships` row for the creating user.
|
||||||
|
- A regression test covers the behavior.
|
||||||
11
specs/070-workspace-create-membership-fix/tasks.md
Normal file
11
specs/070-workspace-create-membership-fix/tasks.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Tasks — 070 Workspace create: ensure creator membership
|
||||||
|
|
||||||
|
## Core
|
||||||
|
- [x] T020 Ensure creating via Workspaces resource grants creator a workspace membership.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- [x] T010 Add regression test for workspace creation creating membership.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- [x] T900 Run Pint on dirty files.
|
||||||
|
- [x] T910 Run targeted Pest test(s).
|
||||||
14
specs/071-tenant-selection-workspace-scope/plan.md
Normal file
14
specs/071-tenant-selection-workspace-scope/plan.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Plan — 071 Workspace-scoped tenant selection
|
||||||
|
|
||||||
|
## Tech
|
||||||
|
- Laravel 12
|
||||||
|
- Filament v5 + Livewire v4
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
1. Update the Filament tenant provider on `User` (`getTenants()` and `getDefaultTenant()`) to optionally filter by the current workspace id from `WorkspaceContext`.
|
||||||
|
2. Add a feature test asserting `/admin/choose-tenant` only shows tenants from the selected workspace.
|
||||||
|
3. Run Pint and targeted tests.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `app/Models/User.php`
|
||||||
|
- `tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php`
|
||||||
18
specs/071-tenant-selection-workspace-scope/spec.md
Normal file
18
specs/071-tenant-selection-workspace-scope/spec.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Spec — 071 Workspace-scoped tenant selection
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Tenant selection and the Filament tenant menu were not scoped to the currently selected workspace. As a result, selecting a newly created workspace could still show tenants from a different workspace (e.g. “Entra ID (DEV)”), leading to confusing flows where tenant-scoped pages show empty lists.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Only show tenants belonging to the currently selected workspace in:
|
||||||
|
- `/admin/choose-tenant`
|
||||||
|
- Filament tenant menu dropdown
|
||||||
|
- Keep behavior unchanged when no workspace is selected.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- Auto-creating tenants when a workspace is created.
|
||||||
|
- Changing authorization rules beyond filtering the selectable tenant list.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- With workspace A selected, a user who is a member of tenants in A and B only sees A’s tenants on `/admin/choose-tenant`.
|
||||||
|
- Regression test covers the behavior.
|
||||||
11
specs/071-tenant-selection-workspace-scope/tasks.md
Normal file
11
specs/071-tenant-selection-workspace-scope/tasks.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Tasks — 071 Workspace-scoped tenant selection
|
||||||
|
|
||||||
|
## Core
|
||||||
|
- [x] T020 Scope `User::getTenants()` and `User::getDefaultTenant()` to current workspace when selected.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- [x] T010 Add regression test for workspace-scoped choose-tenant.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- [x] T900 Run Pint on dirty files.
|
||||||
|
- [x] T910 Run targeted Pest test(s).
|
||||||
32
specs/072-managed-tenants-workspace-enforcement/plan.md
Normal file
32
specs/072-managed-tenants-workspace-enforcement/plan.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Plan — 072 Managed Tenants workspace context enforcement
|
||||||
|
|
||||||
|
## Tech
|
||||||
|
- Laravel 12
|
||||||
|
- Filament v5 + Livewire v4
|
||||||
|
- Pest v4
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
1. Treat `/admin/w/{workspace}/...` as the portfolio / workspace entry space.
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Files (expected)
|
||||||
|
- `routes/web.php`
|
||||||
|
- `app/Providers/Filament/AdminPanelProvider.php`
|
||||||
|
- `app/Http/Middleware/EnsureWorkspaceSelected.php`
|
||||||
|
- `app/Support/Middleware/DenyNonMemberTenantAccess.php` (or `EnsureFilamentTenantSelected.php`, depending on existing enforcement location)
|
||||||
|
- `app/Filament/Pages/ManagedTenants/*` (legacy redirects / removal)
|
||||||
|
- New/updated workspace landing page under `app/Filament/Pages/Workspaces/*` (or equivalent)
|
||||||
|
- Pest tests in `tests/Feature/Routing/` or `tests/Feature/Filament/`
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
- Feature test: `/admin/managed-tenants` redirects to `/admin/w/{workspace}/managed-tenants` when workspace is selected.
|
||||||
|
- Feature test: `/admin/t/{tenant}` returns 404 when workspace context missing.
|
||||||
|
- Feature test: `/admin/t/{tenant}` returns 404 when tenant.workspace_id != current workspace.
|
||||||
|
- Optional: workspace landing lists only workspace tenants.
|
||||||
34
specs/072-managed-tenants-workspace-enforcement/spec.md
Normal file
34
specs/072-managed-tenants-workspace-enforcement/spec.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Spec — 072 Managed Tenants workspace context enforcement
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Managed Tenant pages exist in an unscoped URL space (`/admin/managed-tenants/*`) while Managed Tenants are product-scoped to a Workspace (MSP portfolio). This makes workspace context feel optional and allows confusing / insecure navigation patterns where tenant context and workspace context can drift.
|
||||||
|
|
||||||
|
## Mental model (source of truth)
|
||||||
|
- **Managed Tenant** = the Entra/Intune tenant. All policy/backup/drift/inventory features are always scoped to a Managed Tenant.
|
||||||
|
- In code: Filament tenancy (`/admin/t/{tenant_external_id}/...`).
|
||||||
|
- **Workspace** = portfolio container. Controls which Managed Tenants a user can see + portfolio-level settings.
|
||||||
|
- In code: session + `last_workspace_id` + middleware (not Filament tenancy).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Workspace becomes a real, enforced context for all tenant-scoped pages.
|
||||||
|
- Keep Filament tenancy URL space unchanged: `/admin/t/{tenant_external_id}/...`.
|
||||||
|
- 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/*`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- Redesigning all navigation IA or introducing a second Filament panel.
|
||||||
|
- Migrating existing tenant data beyond enforcing `tenants.workspace_id` consistency.
|
||||||
|
|
||||||
|
## Hard rule (security / enterprise)
|
||||||
|
When accessing `/admin/t/{tenant}` routes:
|
||||||
|
- `current_workspace_id` must be set, and
|
||||||
|
- `tenant.workspace_id == current_workspace_id`, and
|
||||||
|
- user must be a member of the workspace (and/or tenant, per current auth model).
|
||||||
|
Otherwise: **deny as not found** (404).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- `/admin/managed-tenants/*` does not act as a primary UX entry point anymore (redirects to workspace-scoped UX).
|
||||||
|
- `/admin/w/{workspace}/managed-tenants` exists as the primary portfolio landing for Managed Tenants.
|
||||||
|
- Tenant switcher only shows tenants from the current workspace.
|
||||||
|
- Visiting `/admin/t/{tenant}` with missing or mismatched workspace context results in 404.
|
||||||
|
- Pest tests cover redirects + workspace/tenant mismatch denial.
|
||||||
20
specs/072-managed-tenants-workspace-enforcement/tasks.md
Normal file
20
specs/072-managed-tenants-workspace-enforcement/tasks.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Tasks — 072 Managed Tenants workspace context enforcement
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
- [x] T001 Confirm legacy managed-tenants routes and current workspace middleware behavior.
|
||||||
|
|
||||||
|
## Tests (TDD)
|
||||||
|
- [x] T010 Add regression test: `/admin/managed-tenants` redirects to workspace landing when a workspace is selected.
|
||||||
|
- [x] T020 Add regression test: `/admin/t/{tenant}` is 404 when workspace context is missing.
|
||||||
|
- [x] T030 Add regression test: `/admin/t/{tenant}` is 404 when tenant.workspace_id mismatches current workspace.
|
||||||
|
- [x] T040 Add regression test: `/admin/choose-tenant` redirects to `/admin/choose-workspace` when workspace is not selected.
|
||||||
|
|
||||||
|
## Core
|
||||||
|
- [x] T100 Create workspace-scoped Managed Tenants landing at `/admin/w/{workspace}/managed-tenants`.
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- [x] T900 Run Pint on dirty files.
|
||||||
|
- [x] T910 Run targeted Pest tests.
|
||||||
8
tests/Feature/AdminNewRedirectTest.php
Normal file
8
tests/Feature/AdminNewRedirectTest.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('redirects /admin/new to /admin/login for guests', function (): void {
|
||||||
|
$this->get('/admin/new')
|
||||||
|
->assertRedirect('/admin/login');
|
||||||
|
});
|
||||||
69
tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php
Normal file
69
tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php
Normal 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');
|
||||||
|
});
|
||||||
35
tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php
Normal file
35
tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php
Normal 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');
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
38
tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php
Normal file
38
tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
|
||||||
|
it('returns 403 for a member without managed-tenant manage capability when accessing edit', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get("/admin/t/{$tenant->external_id}/tenants/{$tenant->id}/edit")
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for a non-member attempting to access a workspace managed-tenant list', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
Tenant::factory()->create(['workspace_id' => $workspace->getKey()]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$otherWorkspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $otherWorkspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'readonly',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->forceFill(['last_workspace_id' => $otherWorkspace->getKey()])->save();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin/w/'.$workspace->slug.'/managed-tenants')
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
17
tests/Feature/ManagedTenants/OnboardingRedirectTest.php
Normal file
17
tests/Feature/ManagedTenants/OnboardingRedirectTest.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
|
it('redirects /admin/new to the canonical managed-tenant onboarding page', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
expect($workspace)->not->toBeNull();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin/new')
|
||||||
|
->assertRedirect('/admin/w/'.$workspace->slug.'/managed-tenants/onboarding');
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
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('redirects legacy managed-tenants entry to workspace landing when workspace is selected', 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/managed-tenants')
|
||||||
|
->assertRedirect("/admin/w/{$workspace->slug}/managed-tenants");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 on tenant routes when workspace context is missing', 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([
|
||||||
|
'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)
|
||||||
|
->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspaceA = Workspace::factory()->create(['slug' => 'ws-a']);
|
||||||
|
$workspaceB = Workspace::factory()->create(['slug' => 'ws-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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantInA = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceA->getKey(),
|
||||||
|
'external_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||||
|
'tenant_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenantInA->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceB->getKey()])
|
||||||
|
->get(TenantDashboard::getUrl(tenant: $tenantInA))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\Support\AssertsNoOutboundHttp;
|
use Tests\Support\AssertsNoOutboundHttp;
|
||||||
use Tests\Support\FailHardGraphClient;
|
use Tests\Support\FailHardGraphClient;
|
||||||
@ -90,6 +93,30 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string
|
|||||||
$user ??= User::factory()->create();
|
$user ??= User::factory()->create();
|
||||||
$tenant ??= Tenant::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([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenant->getKey() => ['role' => $role],
|
$tenant->getKey() => ['role' => $role],
|
||||||
]);
|
]);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user