diff --git a/app/Concerns/.gitkeep b/app/Concerns/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/Concerns/HasPermissions.php b/app/Concerns/HasPermissions.php new file mode 100644 index 0000000000..368a39d7df --- /dev/null +++ b/app/Concerns/HasPermissions.php @@ -0,0 +1,49 @@ +membership(); + + if ($member === null) { + return false; + } + + $granted = $member->role === UserRole::Custom + ? ($member->customRole->permissions ?? collect()) + : collect($member->role->permissions()); + + return array_all($permissions, fn (Permission $permission): bool => $granted->containsStrict($permission)); + } + + private function membership(): ?WorkspaceMember + { + return once(function (): ?WorkspaceMember { + $memberId = session('workspace_member') ?? context('workspace_member'); + + if (! is_string($memberId) || $memberId === '') { + return null; + } + + return $this->memberships() + ->withoutGlobalScope(WorkspaceScope::class) + ->find($memberId); + }); + } +} diff --git a/app/Enums/UserRole.php b/app/Enums/UserRole.php index 6a93a877e6..610dabb446 100644 --- a/app/Enums/UserRole.php +++ b/app/Enums/UserRole.php @@ -6,6 +6,70 @@ enum UserRole: string { + case Root = 'root'; + case Owner = 'owner'; case Admin = 'admin'; + case Member = 'member'; + case Viewer = 'viewer'; + case Billing = 'billing'; case Custom = 'custom'; + + public function label(): string + { + return match ($this) { + self::Root => 'Root', + self::Owner => 'Owner', + self::Admin => 'Admin', + self::Member => 'Member', + self::Viewer => 'Viewer', + self::Billing => 'Billing', + self::Custom => 'Custom', + }; + } + + public function description(): string + { + return match ($this) { + self::Root => 'Unrestricted access to everything.', + self::Owner => 'Full ownership and control over the workspace and all its resources.', + self::Admin => 'Can manage and configure workspace resources.', + self::Member => 'Can contribute and interact with workspace resources.', + self::Viewer => 'Read-only access to workspace resources.', + self::Billing => 'Manages billing and subscription settings.', + self::Custom => 'Custom role with individually assigned permissions.', + }; + } + + /** + * @return list + */ + public function permissions(): array + { + return match ($this) { + self::Root => Permission::cases(), + self::Owner => [ + // Workspaces + Permission::WorkspaceCreate, + Permission::WorkspaceRead, + Permission::WorkspaceUpdate, + Permission::WorkspaceDelete, + ], + self::Admin => [ + // Workspaces + Permission::WorkspaceCreate, + Permission::WorkspaceRead, + Permission::WorkspaceUpdate, + ], + self::Member => [ + // Workspaces + Permission::WorkspaceRead, + ], + self::Viewer => [ + // Workspaces + Permission::WorkspaceRead, + ], + self::Billing => [], + self::Custom => [], + }; + } } diff --git a/app/Http/Middleware/SetWorkspace.php b/app/Http/Middleware/SetWorkspace.php index 06f0748c6f..952f0e31a8 100644 --- a/app/Http/Middleware/SetWorkspace.php +++ b/app/Http/Middleware/SetWorkspace.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware; +use App\Models\Scopes\WorkspaceScope; use App\Models\User; use Closure; use Illuminate\Http\Request; @@ -23,11 +24,11 @@ public function handle(Request $request, Closure $next): Response if ($request->query->has('workspace')) { $workspaceId = $request->query->getString('workspace'); + $memberId = $this->resolveMembership($user, $workspaceId); - // Abort if the workspace ID was tampered with, does not exist, or the user is not a member. - abort_unless($this->isMember($user, $workspaceId), 404); + abort_if($memberId === null, 404); - $this->persist($workspaceId); + $this->persist($workspaceId, $memberId); return $next($request); } @@ -39,13 +40,13 @@ private function resolveWorkspace(User $user, Request $request): Response { $candidate = $request->cookie('workspace'); - if (is_string($candidate) && $this->isMember($user, $candidate)) { + if (is_string($candidate) && $this->resolveMembership($user, $candidate) !== null) { return $this->redirectWithWorkspace($request, $candidate); } $candidate = session('workspace'); - if (is_string($candidate) && $this->isMember($user, $candidate)) { + if (is_string($candidate) && $this->resolveMembership($user, $candidate) !== null) { return $this->redirectWithWorkspace($request, $candidate); } @@ -69,15 +70,20 @@ private function redirectWithWorkspace(Request $request, string $workspaceId): R return redirect()->to($request->fullUrlWithQuery(['workspace' => $workspaceId])); } - private function isMember(User $user, string $workspaceId): bool + private function resolveMembership(User $user, string $workspaceId): ?string { - return $user->workspaces()->whereKey($workspaceId)->exists(); + $id = $user->memberships() + ->withoutGlobalScope(WorkspaceScope::class) + ->where('workspace_id', $workspaceId) + ->value('id'); + + return is_string($id) ? $id : null; } - private function persist(string $workspaceId): void + private function persist(string $workspaceId, string $memberId): void { - session(['workspace' => $workspaceId]); - context(['workspace' => $workspaceId]); + session(['workspace' => $workspaceId, 'workspace_member' => $memberId]); + context(['workspace' => $workspaceId, 'workspace_member' => $memberId]); cookie()->queue('workspace', $workspaceId, 30 * 24 * 60); } } diff --git a/app/Models/Scopes/MemberAccessScope.php b/app/Models/Scopes/MemberAccessScope.php new file mode 100644 index 0000000000..d613afa26a --- /dev/null +++ b/app/Models/Scopes/MemberAccessScope.php @@ -0,0 +1,35 @@ +where('workspace_member_id', $memberId) + ->where('scopeable_type', $model->getMorphClass()); + + if ($scopeQuery->exists()) { + $builder->whereIn( + $model->qualifyColumn('id'), + $scopeQuery->select('scopeable_id'), + ); + } + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e8fd5caa05..ab82673acd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Concerns\HasPermissions; use Carbon\CarbonInterface; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -33,6 +34,7 @@ final class User extends Authenticatable { /** @use HasFactory */ use HasFactory; + use HasPermissions; use HasUlids; /** diff --git a/database/migrations/2026_03_29_212515_create_workspace_member_scopes_table.php b/database/migrations/2026_03_29_212515_create_workspace_member_scopes_table.php new file mode 100644 index 0000000000..739258c794 --- /dev/null +++ b/database/migrations/2026_03_29_212515_create_workspace_member_scopes_table.php @@ -0,0 +1,20 @@ +foreignUlid('workspace_member_id')->constrained(); + $table->ulidMorphs('scopeable'); + + $table->primary(['workspace_member_id', 'scopeable_type', 'scopeable_id']); + }); + } +};