Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed app/Concerns/.gitkeep
Empty file.
49 changes: 49 additions & 0 deletions app/Concerns/HasPermissions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace App\Concerns;

use App\Enums\Permission;
use App\Enums\UserRole;
use App\Models\Scopes\WorkspaceScope;
use App\Models\User;
use App\Models\WorkspaceMember;

use function is_string;

/**
* @mixin User
*/
trait HasPermissions
{
public function hasPermission(Permission ...$permissions): bool
{
$member = $this->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);
});
}
}
64 changes: 64 additions & 0 deletions app/Enums/UserRole.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Permission>
*/
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 => [],
};
}
}
26 changes: 16 additions & 10 deletions app/Http/Middleware/SetWorkspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Http\Middleware;

use App\Models\Scopes\WorkspaceScope;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
Expand All @@ -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);
}
Expand All @@ -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);
}

Expand All @@ -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);
}
}
35 changes: 35 additions & 0 deletions app/Models/Scopes/MemberAccessScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\DB;

use function is_string;

final class MemberAccessScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$memberId = session('workspace_member') ?? context('workspace_member');

if (! is_string($memberId) || $memberId === '') {
return;
}

$scopeQuery = DB::table('workspace_member_scopes')
->where('workspace_member_id', $memberId)
->where('scopeable_type', $model->getMorphClass());

if ($scopeQuery->exists()) {
$builder->whereIn(
$model->qualifyColumn('id'),
$scopeQuery->select('scopeable_id'),
);
}
}
}
2 changes: 2 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Models;

use App\Concerns\HasPermissions;
use Carbon\CarbonInterface;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Hidden;
Expand Down Expand Up @@ -33,6 +34,7 @@ final class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory;
use HasPermissions;
use HasUlids;

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('workspace_member_scopes', function (Blueprint $table): void {
$table->foreignUlid('workspace_member_id')->constrained();
$table->ulidMorphs('scopeable');

$table->primary(['workspace_member_id', 'scopeable_type', 'scopeable_id']);
});
}
};