diff --git a/app/Actions/Permissions/PermissionService.php b/app/Actions/Permissions/PermissionService.php new file mode 100644 index 0000000..5ab9acf --- /dev/null +++ b/app/Actions/Permissions/PermissionService.php @@ -0,0 +1,343 @@ + $permissions + */ + public function sync(Model $assignee, ?Model $scope, array $permissions): void + { + $normalizedPermissions = $this->normalizePermissions($permissions, $assignee, $scope); + + DB::transaction(function () use ($assignee, $normalizedPermissions, $scope): void { + $this->assignmentQuery($assignee, $scope)->delete(); + + if ($normalizedPermissions === []) { + return; + } + + PermissionAssignment::query()->insert( + array_map( + fn (Permission $permission): array => [ + 'permission' => $permission->value, + 'assignee_id' => $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + 'created_at' => now(), + 'scope_id' => $scope?->getKey(), + 'scope_type' => $scope?->getMorphClass(), + 'updated_at' => now(), + ], + $normalizedPermissions, + ), + ); + }); + } + + public function grant(Model $assignee, Permission|string $permission, ?Model $scope = null): void + { + $normalizedPermission = $this->normalizePermission($permission); + $this->assertAssignmentContext($normalizedPermission, $assignee, $scope); + + $alreadyExists = $this->assignmentQuery($assignee, $scope) + ->where('permission', $normalizedPermission->value) + ->exists(); + + if ($alreadyExists) { + return; + } + + PermissionAssignment::query()->create([ + 'permission' => $normalizedPermission->value, + 'assignee_id' => $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + 'scope_id' => $scope?->getKey(), + 'scope_type' => $scope?->getMorphClass(), + ]); + } + + public function revoke(Model $assignee, Permission|string $permission, ?Model $scope = null): void + { + $normalizedPermission = $this->normalizePermission($permission); + $this->assertAssignmentContext($normalizedPermission, $assignee, $scope); + + $this->assignmentQuery($assignee, $scope) + ->where('permission', $normalizedPermission->value) + ->delete(); + } + + public function allows(User $user, Permission|string $permission, ?Model $scope = null): bool + { + $requiredPermission = $this->normalizePermission($permission); + + return collect($this->effectivePermissions($user, $scope)) + ->contains(fn (Permission $effectivePermission): bool => $effectivePermission === $requiredPermission); + } + + /** + * @return array + */ + public function effectivePermissions(User $user, ?Model $scope = null): array + { + if ($this->hasGlobalAdmin($user)) { + return Permission::cases(); + } + + /** @var Collection $permissions */ + $permissions = $this->relevantAssignments($user, $scope) + ->map(fn (PermissionAssignment $assignment): Permission => $assignment->permission) + ->values(); + + return $this->expandPermissions($permissions->all()); + } + + /** + * @return array + */ + public function permissionOptions(Model|string $assignee, Model|string|null $scope): array + { + return Permission::options( + Permission::assignableFor($assignee, $scope), + ); + } + + /** + * @return array> + */ + public function assignmentMap(string $assigneeClass, ?Model $scope = null): array + { + /** @var Model $assignee */ + $assignee = new $assigneeClass; + + /** @var Collection $assignments */ + $assignments = PermissionAssignment::query() + ->select(['assignee_id', 'permission']) + ->where('assignee_type', $assignee->getMorphClass()) + ->tap(fn (Builder $query): Builder => $this->applyScopeConstraint($query, $scope)) + ->orderBy('permission') + ->get(); + + return $assignments + ->groupBy(fn (PermissionAssignment $assignment): int => (int) $assignment->assignee_id) + ->map(fn (Collection $group): array => $group + ->map(fn (PermissionAssignment $assignment): string => $assignment->permission->value) + ->values() + ->all()) + ->all(); + } + + /** + * @return array + */ + public function viewableProjectIds(User $user): array + { + if ($this->hasGlobalAdmin($user)) { + return Project::query() + ->pluck('id') + ->map(fn (mixed $projectId): int => (int) $projectId) + ->all(); + } + + /** @var Collection $assignments */ + $assignments = PermissionAssignment::query() + ->select(['scope_id', 'permission']) + ->where('scope_type', (new Project)->getMorphClass()) + ->where(function (Builder $query) use ($user): void { + $query->where(function (Builder $directAssignmentsQuery) use ($user): void { + $directAssignmentsQuery + ->where('assignee_type', $user->getMorphClass()) + ->where('assignee_id', $user->getKey()); + }); + + $teamIds = $user->teams() + ->pluck('teams.id') + ->map(fn (mixed $teamId): int => (int) $teamId) + ->all(); + + if ($teamIds !== []) { + $query->orWhere(function (Builder $teamAssignmentsQuery) use ($teamIds): void { + $teamAssignmentsQuery + ->where('assignee_type', (new Team)->getMorphClass()) + ->whereIn('assignee_id', $teamIds); + }); + } + }) + ->get(); + + return $assignments + ->filter(fn (PermissionAssignment $assignment): bool => $assignment->permission->includes(Permission::ProjectRead)) + ->pluck('scope_id') + ->filter() + ->map(fn (mixed $projectId): int => (int) $projectId) + ->unique() + ->values() + ->all(); + } + + /** + * @return array + */ + public function manageableTeamIds(User $user): array + { + if ($this->hasGlobalAdmin($user)) { + return Team::query() + ->pluck('id') + ->map(fn (mixed $teamId): int => (int) $teamId) + ->all(); + } + + /** @var Collection $assignments */ + $assignments = PermissionAssignment::query() + ->select(['scope_id', 'permission']) + ->where('assignee_type', $user->getMorphClass()) + ->where('assignee_id', $user->getKey()) + ->where('scope_type', (new Team)->getMorphClass()) + ->get(); + + return $assignments + ->filter(fn (PermissionAssignment $assignment): bool => $assignment->permission->includes(Permission::TeamManage)) + ->pluck('scope_id') + ->filter() + ->map(fn (mixed $teamId): int => (int) $teamId) + ->unique() + ->values() + ->all(); + } + + public function canManageAnyTeam(User $user): bool + { + return $this->manageableTeamIds($user) !== []; + } + + private function hasGlobalAdmin(User $user): bool + { + return PermissionAssignment::query() + ->where('assignee_type', $user->getMorphClass()) + ->where('assignee_id', $user->getKey()) + ->whereNull('scope_type') + ->whereNull('scope_id') + ->where('permission', Permission::GlobalAdmin->value) + ->exists(); + } + + /** + * @return Collection + */ + private function relevantAssignments(User $user, ?Model $scope): Collection + { + $query = PermissionAssignment::query(); + $this->applyScopeConstraint($query, $scope); + + $query->where(function (Builder $permissionQuery) use ($scope, $user): void { + $permissionQuery->where(function (Builder $directAssignmentsQuery) use ($user): void { + $directAssignmentsQuery + ->where('assignee_type', $user->getMorphClass()) + ->where('assignee_id', $user->getKey()); + }); + + if ($scope instanceof Project) { + $teamIds = $user->teams() + ->pluck('teams.id') + ->map(fn (mixed $teamId): int => (int) $teamId) + ->all(); + + if ($teamIds !== []) { + $permissionQuery->orWhere(function (Builder $teamAssignmentsQuery) use ($teamIds): void { + $teamAssignmentsQuery + ->where('assignee_type', (new Team)->getMorphClass()) + ->whereIn('assignee_id', $teamIds); + }); + } + } + }); + + return $query->get(); + } + + /** + * @param array $permissions + * @return array + */ + private function normalizePermissions(array $permissions, Model $assignee, ?Model $scope): array + { + return collect($permissions) + ->map(fn (Permission|string $permission): Permission => $this->normalizePermission($permission)) + ->each(fn (Permission $permission): bool => $this->assertAssignmentContext($permission, $assignee, $scope)) + ->unique(fn (Permission $permission): string => $permission->value) + ->values() + ->all(); + } + + private function normalizePermission(Permission|string $permission): Permission + { + if ($permission instanceof Permission) { + return $permission; + } + + return Permission::from(trim($permission)); + } + + private function assertAssignmentContext(Permission $permission, Model $assignee, ?Model $scope): bool + { + if (! $permission->isAssignableTo($assignee) || ! $permission->matchesScope($scope)) { + throw new InvalidArgumentException("The {$permission->value} permission cannot be assigned in this context."); + } + + return true; + } + + private function assignmentQuery(Model $assignee, ?Model $scope): Builder + { + $query = PermissionAssignment::query() + ->where('assignee_type', $assignee->getMorphClass()) + ->where('assignee_id', $assignee->getKey()); + + $this->applyScopeConstraint($query, $scope); + + return $query; + } + + private function applyScopeConstraint(Builder $query, ?Model $scope): Builder + { + if ($scope === null) { + return $query + ->whereNull('scope_type') + ->whereNull('scope_id'); + } + + return $query + ->where('scope_type', $scope->getMorphClass()) + ->where('scope_id', $scope->getKey()); + } + + /** + * @param array $permissions + * @return array + */ + private function expandPermissions(array $permissions): array + { + $expandedPermissions = []; + + foreach ($permissions as $permission) { + $expandedPermissions[$permission->value] = $permission; + + foreach ($permission->impliedPermissions() as $impliedPermission) { + $expandedPermissions[$impliedPermission->value] = $impliedPermission; + } + } + + return array_values($expandedPermissions); + } +} diff --git a/app/Actions/ProjectPermissions/ProjectPermissionService.php b/app/Actions/ProjectPermissions/ProjectPermissionService.php deleted file mode 100644 index 87ba664..0000000 --- a/app/Actions/ProjectPermissions/ProjectPermissionService.php +++ /dev/null @@ -1,247 +0,0 @@ - - */ - public const PERMISSIONS = [ - 'create', - 'view', - 'edit', - 'delete', - ]; - - /** - * @return array - */ - public static function permissions(): array - { - return self::PERMISSIONS; - } - - /** - * @return array - */ - public function permissionOptions(): array - { - return collect(self::PERMISSIONS) - ->map(fn (string $permission): array => [ - 'label' => Str::headline($permission), - 'value' => $permission, - ]) - ->all(); - } - - public function grantAllProjectPermissions(Project $project, User $user): void - { - $this->syncUserPermissions($project, $user, self::PERMISSIONS); - } - - /** - * @param array $permissions - */ - public function syncUserPermissions(Project $project, User $user, array $permissions): void - { - $sanitizedPermissions = $this->sanitizePermissions($permissions); - - DB::transaction(function () use ($project, $user, $sanitizedPermissions): void { - DB::table('project_user_permissions') - ->where('project_id', $project->id) - ->where('user_id', $user->id) - ->delete(); - - if ($sanitizedPermissions === []) { - return; - } - - DB::table('project_user_permissions')->insert( - array_map( - fn (string $permission): array => [ - 'project_id' => $project->id, - 'user_id' => $user->id, - 'permission' => $permission, - 'created_at' => now(), - 'updated_at' => now(), - ], - $sanitizedPermissions, - ), - ); - }); - } - - /** - * @param array $permissions - */ - public function syncTeamPermissions(Project $project, Team $team, array $permissions): void - { - $sanitizedPermissions = $this->sanitizePermissions($permissions); - - DB::transaction(function () use ($project, $team, $sanitizedPermissions): void { - DB::table('project_team_permissions') - ->where('project_id', $project->id) - ->where('team_id', $team->id) - ->delete(); - - if ($sanitizedPermissions === []) { - return; - } - - DB::table('project_team_permissions')->insert( - array_map( - fn (string $permission): array => [ - 'project_id' => $project->id, - 'team_id' => $team->id, - 'permission' => $permission, - 'created_at' => now(), - 'updated_at' => now(), - ], - $sanitizedPermissions, - ), - ); - }); - } - - /** - * @param array $userIds - */ - public function syncTeamMembers(Team $team, array $userIds): void - { - $team->users()->sync($userIds); - } - - public function userHasPermission(User $user, Project $project, string $permission): bool - { - if ($user->is_admin) { - return true; - } - - $sanitizedPermission = Str::lower(trim($permission)); - - if (! in_array($sanitizedPermission, self::PERMISSIONS, true)) { - return false; - } - - $hasDirectPermission = DB::table('project_user_permissions') - ->where('project_id', $project->id) - ->where('user_id', $user->id) - ->where('permission', $sanitizedPermission) - ->exists(); - - if ($hasDirectPermission) { - return true; - } - - return DB::table('project_team_permissions') - ->join('team_user', 'team_user.team_id', '=', 'project_team_permissions.team_id') - ->where('project_team_permissions.project_id', $project->id) - ->where('team_user.user_id', $user->id) - ->where('project_team_permissions.permission', $sanitizedPermission) - ->exists(); - } - - public function userCanViewProject(User $user, Project $project): bool - { - if ($user->is_admin) { - return true; - } - - return in_array($project->id, $this->viewableProjectIds($user), true); - } - - public function userCanManageProjectAccess(User $user, Project $project): bool - { - return $this->userHasPermission($user, $project, 'edit'); - } - - /** - * @return array - */ - public function viewableProjectIds(User $user): array - { - if ($user->is_admin) { - return Project::query()->pluck('id')->all(); - } - - $directProjectIds = DB::table('project_user_permissions') - ->where('user_id', $user->id) - ->pluck('project_id'); - - $teamProjectIds = DB::table('project_team_permissions') - ->join('team_user', 'team_user.team_id', '=', 'project_team_permissions.team_id') - ->where('team_user.user_id', $user->id) - ->pluck('project_team_permissions.project_id'); - - return $directProjectIds - ->merge($teamProjectIds) - ->map(fn (mixed $projectId): int => (int) $projectId) - ->unique() - ->values() - ->all(); - } - - /** - * @return array> - */ - public function userPermissionMap(Project $project): array - { - /** @var Collection $permissions */ - $permissions = DB::table('project_user_permissions') - ->select(['user_id', 'permission']) - ->where('project_id', $project->id) - ->orderBy('permission') - ->get(); - - return $permissions - ->groupBy(fn (object $permission): int => (int) $permission->user_id) - ->map(fn (Collection $group): array => $group - ->pluck('permission') - ->values() - ->all()) - ->all(); - } - - /** - * @return array> - */ - public function teamPermissionMap(Project $project): array - { - /** @var Collection $permissions */ - $permissions = DB::table('project_team_permissions') - ->select(['team_id', 'permission']) - ->where('project_id', $project->id) - ->orderBy('permission') - ->get(); - - return $permissions - ->groupBy(fn (object $permission): int => (int) $permission->team_id) - ->map(fn (Collection $group): array => $group - ->pluck('permission') - ->values() - ->all()) - ->all(); - } - - /** - * @param array $permissions - * @return array - */ - private function sanitizePermissions(array $permissions): array - { - return collect($permissions) - ->map(fn (mixed $permission): string => Str::lower(trim((string) $permission))) - ->filter(fn (string $permission): bool => in_array($permission, self::PERMISSIONS, true)) - ->unique() - ->values() - ->all(); - } -} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php new file mode 100644 index 0000000..9300cbb --- /dev/null +++ b/app/Enums/Permission.php @@ -0,0 +1,175 @@ + 'Global admin', + self::ProjectCreate => 'Project create', + self::ProjectRead => 'Project read', + self::ProjectWrite => 'Project write', + self::ProjectAdmin => 'Project admin', + self::TeamInvite => 'Team invite', + self::TeamManage => 'Team manage', + self::TeamAdmin => 'Team admin', + }; + } + + public function scope(): PermissionScope + { + return match ($this) { + self::GlobalAdmin, self::ProjectCreate => PermissionScope::Global, + self::ProjectRead, self::ProjectWrite, self::ProjectAdmin => PermissionScope::Project, + self::TeamInvite, self::TeamManage, self::TeamAdmin => PermissionScope::Team, + }; + } + + /** + * @return array + */ + public function impliedPermissions(): array + { + return match ($this) { + self::GlobalAdmin => array_values(array_filter( + self::cases(), + fn (self $permission): bool => $permission !== self::GlobalAdmin, + )), + self::ProjectAdmin => [self::ProjectWrite, self::ProjectRead], + self::ProjectWrite => [self::ProjectRead], + self::TeamAdmin => [self::TeamManage, self::TeamInvite], + default => [], + }; + } + + public function includes(self $permission): bool + { + if ($this === $permission) { + return true; + } + + foreach ($this->impliedPermissions() as $impliedPermission) { + if ($impliedPermission->includes($permission)) { + return true; + } + } + + return false; + } + + public function isAssignableTo(Model|string $assignee): bool + { + $assigneeClass = $assignee instanceof Model + ? $assignee::class + : $assignee; + + return match ($this->scope()) { + PermissionScope::Global => is_a($assigneeClass, User::class, true), + PermissionScope::Project => is_a($assigneeClass, User::class, true) || is_a($assigneeClass, Team::class, true), + PermissionScope::Team => is_a($assigneeClass, User::class, true), + }; + } + + public function matchesScope(Model|string|null $scope): bool + { + $scopeClass = $scope instanceof Model + ? $scope::class + : $scope; + + return match ($this->scope()) { + PermissionScope::Global => $scopeClass === null, + PermissionScope::Project => is_a($scopeClass, Project::class, true), + PermissionScope::Team => is_a($scopeClass, Team::class, true), + }; + } + + /** + * @param array|null $permissions + * @return array + */ + public static function values(?array $permissions = null): array + { + return array_map( + fn (self $permission): string => $permission->value, + $permissions ?? self::cases(), + ); + } + + /** + * @return array + */ + public static function globalAssignable(): array + { + return [ + self::GlobalAdmin, + self::ProjectCreate, + ]; + } + + /** + * @return array + */ + public static function projectAssignable(): array + { + return [ + self::ProjectRead, + self::ProjectWrite, + self::ProjectAdmin, + ]; + } + + /** + * @return array + */ + public static function teamAssignable(): array + { + return [ + self::TeamInvite, + self::TeamManage, + self::TeamAdmin, + ]; + } + + /** + * @return array + */ + public static function assignableFor(Model|string $assignee, Model|string|null $scope): array + { + return array_values(array_filter( + self::cases(), + fn (self $permission): bool => $permission->isAssignableTo($assignee) + && $permission->matchesScope($scope), + )); + } + + /** + * @param array $permissions + * @return array + */ + public static function options(array $permissions): array + { + return array_map( + fn (self $permission): array => [ + 'label' => $permission->label(), + 'value' => $permission->value, + ], + $permissions, + ); + } +} diff --git a/app/Enums/PermissionScope.php b/app/Enums/PermissionScope.php new file mode 100644 index 0000000..698423b --- /dev/null +++ b/app/Enums/PermissionScope.php @@ -0,0 +1,10 @@ + $this->permissionService->assignmentMap(User::class), + 'permissionOptions' => $this->permissionService->permissionOptions(User::class, null), 'status' => session('status'), 'users' => User::query() - ->orderByDesc('is_admin') ->orderBy('name') ->get(), ]); } - public function update(UpdateUserAdminStatusRequest $request, User $user): RedirectResponse + public function update(SyncUserGlobalPermissionsRequest $request, User $user): RedirectResponse { $validated = $request->validated(); - $user->forceFill([ - 'is_admin' => Arr::get($validated, 'is_admin'), - ])->save(); + $permissions = Arr::get($validated, 'permissions', []); - $status = $user->is_admin - ? "{$user->name} now has admin access." - : "{$user->name} no longer has admin access."; + $this->permissionService->sync($user, null, $permissions); - return to_route('admin.users.index')->with('status', $status); + return to_route('admin.users.index') + ->with('status', "{$user->name} global permissions were updated."); } } diff --git a/app/Http/Controllers/ProjectBacklogController.php b/app/Http/Controllers/ProjectBacklogController.php index 86908da..176b64d 100644 --- a/app/Http/Controllers/ProjectBacklogController.php +++ b/app/Http/Controllers/ProjectBacklogController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\CreateProjectTicket; +use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; @@ -16,7 +17,7 @@ class ProjectBacklogController extends Controller { public function __construct( private readonly CreateProjectTicket $createProjectTicket, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function index(Request $request, Project $project): Response @@ -24,14 +25,14 @@ class ProjectBacklogController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanViewProject($user, $project), + $this->permissionService->allows($user, Permission::ProjectRead, $project), 403, ); return Inertia::render('projects/Backlog', [ 'boardColumns' => $project->boardColumns()->get(), - 'canEditTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'), - 'canCreateTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'create'), + 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'project' => $project, 'status' => session('status'), 'ticketPriorities' => $project->ticketPriorities()->get(), @@ -51,7 +52,7 @@ class ProjectBacklogController extends Controller $validated = $request->validated(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'create'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectBoardController.php b/app/Http/Controllers/ProjectBoardController.php index e6bae65..45cf5ae 100644 --- a/app/Http/Controllers/ProjectBoardController.php +++ b/app/Http/Controllers/ProjectBoardController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\CreateTicketWorklogEntry; +use App\Enums\Permission; use App\Http\Requests\MoveProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; @@ -17,7 +18,7 @@ class ProjectBoardController extends Controller { public function __construct( private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function index(Request $request, Project $project): Response @@ -25,7 +26,7 @@ class ProjectBoardController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanViewProject($user, $project), + $this->permissionService->allows($user, Permission::ProjectRead, $project), 403, ); @@ -36,7 +37,7 @@ class ProjectBoardController extends Controller ->with(['ticketPriority', 'ticketType']), ]) ->get(), - 'canEditTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'project' => $project, 'status' => session('status'), ]); @@ -50,7 +51,7 @@ class ProjectBoardController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectBoardSettingsController.php b/app/Http/Controllers/ProjectBoardSettingsController.php index b6aca5b..cfc0535 100644 --- a/app/Http/Controllers/ProjectBoardSettingsController.php +++ b/app/Http/Controllers/ProjectBoardSettingsController.php @@ -2,7 +2,8 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Http\Requests\StoreBoardColumnRequest; use App\Http\Requests\UpdateBoardColumnRequest; use App\Models\BoardColumn; @@ -18,13 +19,13 @@ use Inertia\Response; class ProjectBoardSettingsController extends Controller { public function __construct( - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function show(Request $request, Project $project): Response { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); @@ -40,7 +41,7 @@ class ProjectBoardSettingsController extends Controller Project $project, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); @@ -68,7 +69,7 @@ class ProjectBoardSettingsController extends Controller BoardColumn $boardColumn, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); @@ -91,7 +92,7 @@ class ProjectBoardSettingsController extends Controller BoardColumn $boardColumn, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 10ed4fa..a5be4ae 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\SyncProjectPermissionsRequest; @@ -20,7 +21,7 @@ class ProjectController extends Controller { public function __construct( private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function index(Request $request): Response @@ -30,12 +31,13 @@ class ProjectController extends Controller $projectQuery = Project::query() ->orderByDesc('id'); - if (! $user->is_admin) { - $viewableProjectIds = $this->projectPermissionService->viewableProjectIds($user); + if (! $this->permissionService->allows($user, Permission::GlobalAdmin)) { + $viewableProjectIds = $this->permissionService->viewableProjectIds($user); if ($viewableProjectIds === []) { return Inertia::render('projects/Index', [ 'projects' => [], + 'status' => session('status'), ]); } @@ -49,8 +51,13 @@ class ProjectController extends Controller ]); } - public function create(): Response + public function create(Request $request): Response { + abort_unless( + $this->permissionService->allows($request->user(), Permission::ProjectCreate), + 403, + ); + return Inertia::render('projects/Create'); } @@ -58,7 +65,7 @@ class ProjectController extends Controller { $project = Project::query()->create($request->validated()); $this->initializeProjectTicketMetadata->handle($project); - $this->projectPermissionService->grantAllProjectPermissions($project, $request->user()); + $this->permissionService->grant($request->user(), Permission::ProjectAdmin, $project); return to_route('projects.show', $project); } @@ -68,12 +75,12 @@ class ProjectController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanViewProject($user, $project), + $this->permissionService->allows($user, Permission::ProjectRead, $project), 403, ); return Inertia::render('projects/Show', [ - 'canManageAccess' => $this->projectPermissionService->userCanManageProjectAccess($user, $project), + 'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 'project' => $project, ]); } @@ -90,16 +97,16 @@ class ProjectController extends Controller { $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($user, $project), + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 403, ); return Inertia::render('projects/Access', [ - 'directPermissions' => $this->projectPermissionService->userPermissionMap($project), - 'permissionOptions' => $this->projectPermissionService->permissionOptions(), + 'directPermissions' => $this->permissionService->assignmentMap(User::class, $project), + 'permissionOptions' => $this->permissionService->permissionOptions(User::class, Project::class), 'project' => $project, 'status' => session('status'), - 'teamPermissions' => $this->projectPermissionService->teamPermissionMap($project), + 'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project), 'teams' => Team::query() ->with('users') ->orderBy('name') @@ -125,14 +132,14 @@ class ProjectController extends Controller ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); $permissions = Arr::get($validated, 'permissions', []); - $this->projectPermissionService->syncUserPermissions($project, $user, $permissions); + $this->permissionService->sync($user, $project, $permissions); return to_route('projects.access', $project) ->with('status', "{$user->name} permissions were updated."); @@ -144,14 +151,14 @@ class ProjectController extends Controller Team $team, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); $permissions = Arr::get($validated, 'permissions', []); - $this->projectPermissionService->syncTeamPermissions($project, $team, $permissions); + $this->permissionService->sync($team, $project, $permissions); return to_route('projects.access', $project) ->with('status', "{$team->name} permissions were updated."); diff --git a/app/Http/Controllers/ProjectRepositorySettingsController.php b/app/Http/Controllers/ProjectRepositorySettingsController.php index a2c3914..688e6d1 100644 --- a/app/Http/Controllers/ProjectRepositorySettingsController.php +++ b/app/Http/Controllers/ProjectRepositorySettingsController.php @@ -4,7 +4,8 @@ namespace App\Http\Controllers; use App\Actions\Git\Data\GitRepository; use App\Actions\Git\GitProviderManager; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Http\Requests\StoreRepositoryConnectionRequest; use App\Http\Requests\UpdateRepositoryConnectionRequest; use App\Models\Project; @@ -24,14 +25,14 @@ class ProjectRepositorySettingsController extends Controller { public function __construct( private readonly GitProviderManager $gitProviderManager, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function show(Request $request, Project $project): Response { $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($user, $project), + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 403, ); @@ -66,7 +67,7 @@ class ProjectRepositorySettingsController extends Controller ): RedirectResponse { $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($user, $project), + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 403, ); @@ -96,7 +97,7 @@ class ProjectRepositorySettingsController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($user, $project), + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 403, ); @@ -128,7 +129,7 @@ class ProjectRepositorySettingsController extends Controller RepositoryConnection $repositoryConnection, ): RedirectResponse { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); diff --git a/app/Http/Controllers/ProjectTicketBranchController.php b/app/Http/Controllers/ProjectTicketBranchController.php index 86937c8..672d11d 100644 --- a/app/Http/Controllers/ProjectTicketBranchController.php +++ b/app/Http/Controllers/ProjectTicketBranchController.php @@ -5,7 +5,8 @@ namespace App\Http\Controllers; use App\Actions\Git\Data\GitBranch; use App\Actions\Git\GitProviderManager; use App\Actions\Git\ValidateGitBranchName; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketBranchRequest; use App\Http\Requests\ValidateProjectTicketBranchRequest; use App\Models\Project; @@ -23,7 +24,7 @@ class ProjectTicketBranchController extends Controller public function __construct( private readonly GitProviderManager $gitProviderManager, private readonly ValidateGitBranchName $validateGitBranchName, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function validate( @@ -34,7 +35,7 @@ class ProjectTicketBranchController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); @@ -110,7 +111,7 @@ class ProjectTicketBranchController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index ab6a878..aa28d0c 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -5,8 +5,9 @@ namespace App\Http\Controllers; use App\Actions\Git\BuildSuggestedTicketBranchName; use App\Actions\Git\ResolveTicketBranchMatches; use App\Actions\Git\ResolveTicketPullRequestMatches; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\UpdateProjectTicket; +use App\Enums\Permission; use App\Http\Requests\UpdateProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; @@ -22,7 +23,7 @@ class ProjectTicketController extends Controller { public function __construct( private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches, private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches, private readonly UpdateProjectTicket $updateProjectTicket, @@ -33,14 +34,14 @@ class ProjectTicketController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userCanViewProject($user, $project), + $this->permissionService->allows($user, Permission::ProjectRead, $project), 403, ); $excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)]; return Inertia::render('projects/Ticket', [ - 'canEditTicket' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + 'canEditTicket' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'childTicketTree' => $this->buildChildTicketTree($ticket), 'linkedTickets' => $this->linkedTickets($ticket), 'parentTicketOptions' => Ticket::query() @@ -103,7 +104,7 @@ class ProjectTicketController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectTicketLinkController.php b/app/Http/Controllers/ProjectTicketLinkController.php index da67c21..b558502 100644 --- a/app/Http/Controllers/ProjectTicketLinkController.php +++ b/app/Http/Controllers/ProjectTicketLinkController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\CreateTicketWorklogEntry; +use App\Enums\Permission; use App\Http\Requests\StoreTicketLinkRequest; use App\Models\Project; use App\Models\Ticket; @@ -17,7 +18,7 @@ class ProjectTicketLinkController extends Controller { public function __construct( private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function store( @@ -28,7 +29,7 @@ class ProjectTicketLinkController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); @@ -93,7 +94,7 @@ class ProjectTicketLinkController extends Controller $user = $request->user(); abort_unless( - $this->projectPermissionService->userHasPermission($user, $project, 'edit'), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), 403, ); diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index 3827cf9..4913d8c 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Enums\Permission; use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreTicketFieldDefinitionRequest; use App\Http\Requests\UpdateProjectNamedSettingRequest; @@ -26,7 +27,7 @@ class ProjectTicketMetadataSettingsController extends Controller { public function __construct( private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} public function show(Request $request, Project $project): Response @@ -355,7 +356,7 @@ class ProjectTicketMetadataSettingsController extends Controller private function abortUnlessCanManage(Request $request, Project $project): void { abort_unless( - $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); } diff --git a/app/Http/Controllers/TeamController.php b/app/Http/Controllers/TeamController.php index 68891d7..273e415 100644 --- a/app/Http/Controllers/TeamController.php +++ b/app/Http/Controllers/TeamController.php @@ -2,12 +2,15 @@ namespace App\Http\Controllers; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Http\Requests\StoreTeamRequest; +use App\Http\Requests\SyncTeamUserPermissionsRequest; use App\Http\Requests\UpdateTeamMembersRequest; use App\Models\Team; use App\Models\User; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Arr; use Inertia\Inertia; use Inertia\Response; @@ -15,17 +18,36 @@ use Inertia\Response; class TeamController extends Controller { public function __construct( - private readonly ProjectPermissionService $projectPermissionService, + private readonly PermissionService $permissionService, ) {} - public function index(): Response + public function index(Request $request): Response { + $user = $request->user(); + $canCreateTeams = $this->permissionService->allows($user, Permission::GlobalAdmin); + $manageableTeamIds = $this->permissionService->manageableTeamIds($user); + + abort_unless($canCreateTeams || $manageableTeamIds !== [], 403); + + $teams = Team::query() + ->with('users') + ->when( + ! $canCreateTeams, + fn ($query) => $query->whereIn('id', $manageableTeamIds), + ) + ->orderBy('name') + ->get(); + return Inertia::render('teams/Index', [ + 'canCreateTeams' => $canCreateTeams, + 'permissionOptions' => $this->permissionService->permissionOptions(User::class, Team::class), 'status' => session('status'), - 'teams' => Team::query() - ->with('users') - ->orderBy('name') - ->get() + 'teamPermissions' => $teams + ->mapWithKeys(fn (Team $team): array => [ + $team->id => $this->permissionService->assignmentMap(User::class, $team), + ]) + ->all(), + 'teams' => $teams ->map(fn (Team $team): array => [ 'id' => $team->id, 'name' => $team->name, @@ -44,19 +66,42 @@ class TeamController extends Controller { $team = Team::query()->create($request->validated()); - return to_route('admin.teams.index') + return to_route('teams.index') ->with('status', "{$team->name} was created."); } public function updateMembers(UpdateTeamMembersRequest $request, Team $team): RedirectResponse { - $validated = $request->validated(); + abort_unless( + $this->permissionService->allows($request->user(), Permission::TeamManage, $team), + 403, + ); + $validated = $request->validated(); $userIds = Arr::get($validated, 'user_ids', []); - $this->projectPermissionService->syncTeamMembers($team, $userIds); + $team->users()->sync($userIds); - return to_route('admin.teams.index') + return to_route('teams.index') ->with('status', "{$team->name} members were updated."); } + + public function updateUserPermissions( + SyncTeamUserPermissionsRequest $request, + Team $team, + User $user, + ): RedirectResponse { + abort_unless( + $this->permissionService->allows($request->user(), Permission::TeamManage, $team), + 403, + ); + + $validated = $request->validated(); + $permissions = Arr::get($validated, 'permissions', []); + + $this->permissionService->sync($user, $team, $permissions); + + return to_route('teams.index') + ->with('status', "{$user->name} permissions on {$team->name} were updated."); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..e874437 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,11 +2,17 @@ namespace App\Http\Middleware; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use Illuminate\Http\Request; use Inertia\Middleware; class HandleInertiaRequests extends Middleware { + public function __construct( + private readonly PermissionService $permissionService, + ) {} + /** * The root template that's loaded on the first page visit. * @@ -35,11 +41,18 @@ class HandleInertiaRequests extends Middleware */ public function share(Request $request): array { + $user = $request->user(); + return [ ...parent::share($request), 'name' => config('app.name'), 'auth' => [ - 'user' => $request->user(), + 'permissions' => [ + 'canManageTeams' => $user !== null && $this->permissionService->canManageAnyTeam($user), + 'globalAdmin' => $user !== null && $this->permissionService->allows($user, Permission::GlobalAdmin), + 'projectCreate' => $user !== null && $this->permissionService->allows($user, Permission::ProjectCreate), + ], + 'user' => $user, ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', ]; diff --git a/app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php b/app/Http/Requests/Admin/SyncUserGlobalPermissionsRequest.php similarity index 61% rename from app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php rename to app/Http/Requests/Admin/SyncUserGlobalPermissionsRequest.php index dc7ed33..d53dee0 100644 --- a/app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php +++ b/app/Http/Requests/Admin/SyncUserGlobalPermissionsRequest.php @@ -2,10 +2,12 @@ namespace App\Http\Requests\Admin; +use App\Enums\Permission; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; -class UpdateUserAdminStatusRequest extends FormRequest +class SyncUserGlobalPermissionsRequest extends FormRequest { /** * Determine if the user is authorized to make this request. @@ -23,7 +25,12 @@ class UpdateUserAdminStatusRequest extends FormRequest public function rules(): array { return [ - 'is_admin' => ['required', 'boolean'], + 'permissions' => ['nullable', 'array'], + 'permissions.*' => [ + 'string', + 'distinct', + Rule::in(Permission::values(Permission::globalAssignable())), + ], ]; } } diff --git a/app/Http/Requests/DeleteProjectRequest.php b/app/Http/Requests/DeleteProjectRequest.php index 509d917..bec6563 100644 --- a/app/Http/Requests/DeleteProjectRequest.php +++ b/app/Http/Requests/DeleteProjectRequest.php @@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest { public function authorize(): bool { - return $this->user() !== null; + return $this->user()?->can('access-admin') ?? false; } /** diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index 479af80..6c7eeb0 100644 --- a/app/Http/Requests/StoreProjectRequest.php +++ b/app/Http/Requests/StoreProjectRequest.php @@ -14,7 +14,7 @@ class StoreProjectRequest extends FormRequest */ public function authorize(): bool { - return $this->user() !== null; + return $this->user()?->can('create-project') ?? false; } protected function prepareForValidation(): void diff --git a/app/Http/Requests/SyncProjectPermissionsRequest.php b/app/Http/Requests/SyncProjectPermissionsRequest.php index b3c8bf6..6594521 100644 --- a/app/Http/Requests/SyncProjectPermissionsRequest.php +++ b/app/Http/Requests/SyncProjectPermissionsRequest.php @@ -2,7 +2,7 @@ namespace App\Http\Requests; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Enums\Permission; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -29,7 +29,7 @@ class SyncProjectPermissionsRequest extends FormRequest 'permissions.*' => [ 'string', 'distinct', - Rule::in(ProjectPermissionService::permissions()), + Rule::in(Permission::values(Permission::projectAssignable())), ], ]; } diff --git a/app/Http/Requests/SyncTeamUserPermissionsRequest.php b/app/Http/Requests/SyncTeamUserPermissionsRequest.php new file mode 100644 index 0000000..9a21ff3 --- /dev/null +++ b/app/Http/Requests/SyncTeamUserPermissionsRequest.php @@ -0,0 +1,36 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'permissions' => ['nullable', 'array'], + 'permissions.*' => [ + 'string', + 'distinct', + Rule::in(Permission::values(Permission::teamAssignable())), + ], + ]; + } +} diff --git a/app/Http/Requests/UpdateTeamMembersRequest.php b/app/Http/Requests/UpdateTeamMembersRequest.php index 2c2edd7..8e17244 100644 --- a/app/Http/Requests/UpdateTeamMembersRequest.php +++ b/app/Http/Requests/UpdateTeamMembersRequest.php @@ -13,7 +13,7 @@ class UpdateTeamMembersRequest extends FormRequest */ public function authorize(): bool { - return $this->user()?->can('access-admin') ?? false; + return $this->user() !== null; } /** diff --git a/app/Models/PermissionAssignment.php b/app/Models/PermissionAssignment.php new file mode 100644 index 0000000..dfd4f29 --- /dev/null +++ b/app/Models/PermissionAssignment.php @@ -0,0 +1,37 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'permission' => Permission::class, + ]; + } + + public function assignee(): MorphTo + { + return $this->morphTo(); + } + + public function scope(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 780748b..b7fec0d 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Str; #[Fillable(['name', 'key'])] @@ -16,6 +17,13 @@ class Project extends Model /** @use HasFactory */ use HasFactory; + protected static function booted(): void + { + static::deleting(function (self $project): void { + $project->permissionAssignments()->delete(); + }); + } + protected function key(): Attribute { return Attribute::make( @@ -23,6 +31,11 @@ class Project extends Model ); } + public function permissionAssignments(): MorphMany + { + return $this->morphMany(PermissionAssignment::class, 'scope'); + } + public function boardColumns(): HasMany { return $this->hasMany(BoardColumn::class) diff --git a/app/Models/Team.php b/app/Models/Team.php index 18a1d08..bb54230 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; #[Fillable(['name'])] class Team extends Model @@ -14,6 +15,24 @@ class Team extends Model /** @use HasFactory */ use HasFactory; + protected static function booted(): void + { + static::deleting(function (self $team): void { + $team->permissionAssignments()->delete(); + $team->scopedPermissionAssignments()->delete(); + }); + } + + public function permissionAssignments(): MorphMany + { + return $this->morphMany(PermissionAssignment::class, 'assignee'); + } + + public function scopedPermissionAssignments(): MorphMany + { + return $this->morphMany(PermissionAssignment::class, 'scope'); + } + public function users(): BelongsToMany { return $this->belongsToMany(User::class) diff --git a/app/Models/User.php b/app/Models/User.php index 405dffa..8b0576f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -20,6 +21,13 @@ class User extends Authenticatable /** @use HasFactory */ use HasFactory, Notifiable, TwoFactorAuthenticatable; + protected static function booted(): void + { + static::deleting(function (self $user): void { + $user->permissionAssignments()->delete(); + }); + } + /** * Get the attributes that should be cast. * @@ -29,12 +37,16 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', - 'is_admin' => 'boolean', 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', ]; } + public function permissionAssignments(): MorphMany + { + return $this->morphMany(PermissionAssignment::class, 'assignee'); + } + public function teams(): BelongsToMany { return $this->belongsToMany(Team::class) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 88484df..f00b42a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,13 @@ namespace App\Providers; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; +use App\Models\Project; +use App\Models\Team; use App\Models\User; use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -40,7 +45,18 @@ class AppServiceProvider extends ServiceProvider */ protected function configureAuthorization(): void { - Gate::define('access-admin', fn (User $user): bool => $user->is_admin); + Relation::enforceMorphMap([ + 'project' => Project::class, + 'team' => Team::class, + 'user' => User::class, + ]); + + /** @var PermissionService $permissionService */ + $permissionService = $this->app->make(PermissionService::class); + + Gate::define('access-admin', fn (User $user): bool => $permissionService->allows($user, Permission::GlobalAdmin)); + Gate::define('create-project', fn (User $user): bool => $permissionService->allows($user, Permission::ProjectCreate)); + Gate::define('manage-teams', fn (User $user): bool => $permissionService->canManageAnyTeam($user)); } /** diff --git a/database/factories/PermissionAssignmentFactory.php b/database/factories/PermissionAssignmentFactory.php new file mode 100644 index 0000000..7da6dc9 --- /dev/null +++ b/database/factories/PermissionAssignmentFactory.php @@ -0,0 +1,96 @@ + + */ +class PermissionAssignmentFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'permission' => Permission::ProjectRead->value, + 'assignee_id' => User::factory(), + 'assignee_type' => (new User)->getMorphClass(), + 'scope_id' => Project::factory(), + 'scope_type' => (new Project)->getMorphClass(), + ]; + } + + public function forAssignee(Model $assignee): static + { + return $this->state(fn (): array => [ + 'assignee_id' => $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + ]); + } + + public function forScope(Model $scope): static + { + return $this->state(fn (): array => [ + 'scope_id' => $scope->getKey(), + 'scope_type' => $scope->getMorphClass(), + ]); + } + + public function global(): static + { + return $this->state(fn (): array => [ + 'scope_id' => null, + 'scope_type' => null, + ]); + } + + public function permission(Permission $permission): static + { + return $this->state(fn (): array => [ + 'permission' => $permission->value, + ]); + } + + public function globalAdmin(): static + { + return $this->global()->permission(Permission::GlobalAdmin); + } + + public function projectAdmin(): static + { + return $this->permission(Permission::ProjectAdmin); + } + + public function projectRead(): static + { + return $this->permission(Permission::ProjectRead); + } + + public function projectWrite(): static + { + return $this->permission(Permission::ProjectWrite); + } + + public function teamAdmin(): static + { + return $this->permission(Permission::TeamAdmin); + } + + public function teamInvite(): static + { + return $this->permission(Permission::TeamInvite); + } + + public function teamManage(): static + { + return $this->permission(Permission::TeamManage); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 3148b81..de5d2ba 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\PermissionAssignment; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; @@ -28,7 +29,6 @@ class UserFactory extends Factory 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'is_admin' => false, 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, @@ -64,8 +64,11 @@ class UserFactory extends Factory */ public function admin(): static { - return $this->state(fn (array $attributes) => [ - 'is_admin' => true, - ]); + return $this->afterCreating(function (User $user): void { + PermissionAssignment::factory() + ->forAssignee($user) + ->globalAdmin() + ->create(); + }); } } diff --git a/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php b/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php deleted file mode 100644 index 87295b3..0000000 --- a/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('is_admin')->default(false)->after('email_verified_at'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('is_admin'); - }); - } -}; diff --git a/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php b/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php deleted file mode 100644 index f793b0f..0000000 --- a/database/migrations/2026_03_24_234735_create_project_team_permissions_table.php +++ /dev/null @@ -1,31 +0,0 @@ -foreignId('project_id')->constrained()->cascadeOnDelete(); - $table->foreignId('team_id')->constrained()->cascadeOnDelete(); - $table->string('permission'); - $table->timestamps(); - - $table->primary(['project_id', 'team_id', 'permission']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('project_team_permissions'); - } -}; diff --git a/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php b/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php deleted file mode 100644 index d781549..0000000 --- a/database/migrations/2026_03_24_234735_create_project_user_permissions_table.php +++ /dev/null @@ -1,31 +0,0 @@ -foreignId('project_id')->constrained()->cascadeOnDelete(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('permission'); - $table->timestamps(); - - $table->primary(['project_id', 'user_id', 'permission']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('project_user_permissions'); - } -}; diff --git a/database/migrations/2026_04_12_120000_create_permission_assignments_table.php b/database/migrations/2026_04_12_120000_create_permission_assignments_table.php new file mode 100644 index 0000000..ba93815 --- /dev/null +++ b/database/migrations/2026_04_12_120000_create_permission_assignments_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('permission', 50); + $table->string('assignee_type', 32); + $table->unsignedBigInteger('assignee_id'); + $table->string('scope_type', 32)->nullable(); + $table->unsignedBigInteger('scope_id')->nullable(); + $table->timestamps(); + + $table->index(['assignee_type', 'assignee_id']); + $table->index(['scope_type', 'scope_id']); + $table->unique(['permission', 'assignee_type', 'assignee_id', 'scope_type', 'scope_id'], 'permission_assignments_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('permission_assignments'); + } +}; diff --git a/database/seeders/ProjectAccessSeeder.php b/database/seeders/ProjectAccessSeeder.php index 339a657..c4c2500 100644 --- a/database/seeders/ProjectAccessSeeder.php +++ b/database/seeders/ProjectAccessSeeder.php @@ -2,7 +2,8 @@ namespace Database\Seeders; -use App\Actions\ProjectPermissions\ProjectPermissionService; +use App\Enums\Permission; +use App\Models\PermissionAssignment; use App\Models\Project; use App\Models\Team; use App\Models\User; @@ -15,28 +16,33 @@ class ProjectAccessSeeder extends Seeder */ public function run(): void { - $projectPermissionService = app(ProjectPermissionService::class); - - $projects = Project::query()->get(); - $admins = User::query() - ->where('is_admin', true) - ->get(); - - foreach ($admins as $admin) { - foreach ($projects as $project) { - $projectPermissionService->grantAllProjectPermissions($project, $admin); - } - } - $coreProject = Project::query()->where('key', 'CORE')->first(); + $deliveryTeam = Team::query()->where('name', 'Delivery Team')->first(); $platformTeam = Team::query()->where('name', 'Platform Team')->first(); + $testUser = User::query()->where('email', 'test@example.com')->first(); if ($coreProject !== null && $platformTeam !== null) { - $projectPermissionService->syncTeamPermissions($coreProject, $platformTeam, [ - 'create', - 'edit', - 'view', - ]); + PermissionAssignment::factory() + ->forAssignee($platformTeam) + ->forScope($coreProject) + ->projectWrite() + ->create(); + } + + if ($platformTeam !== null && $testUser !== null) { + PermissionAssignment::factory() + ->forAssignee($testUser) + ->forScope($platformTeam) + ->teamManage() + ->create(); + } + + if ($deliveryTeam !== null && $testUser !== null) { + PermissionAssignment::factory() + ->forAssignee($testUser) + ->forScope($deliveryTeam) + ->permission(Permission::TeamInvite) + ->create(); } } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 422ab25..960bdf0 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Enums\Permission; +use App\Models\PermissionAssignment; use App\Models\User; use Illuminate\Database\Seeder; @@ -12,16 +14,27 @@ class UserSeeder extends Seeder */ public function run(): void { - User::factory()->admin()->create([ + $admin = User::factory()->create([ 'name' => 'Admin User', 'email' => 'admin@kanbangit.test', ]); - User::factory()->create([ + PermissionAssignment::factory() + ->forAssignee($admin) + ->globalAdmin() + ->create(); + + $testUser = User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); + PermissionAssignment::factory() + ->forAssignee($testUser) + ->global() + ->permission(Permission::ProjectCreate) + ->create(); + User::factory(8)->create(); } } diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 5c8302e..a612285 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -23,9 +23,9 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; import { dashboard } from '@/routes'; -import { index as adminTeams } from '@/routes/admin/teams'; import { index as adminUsers } from '@/routes/admin/users'; import { index as projectsIndex } from '@/routes/projects'; +import { index as teamsIndex } from '@/routes/teams'; import type { NavItem } from '@/types'; const page = usePage(); @@ -44,16 +44,18 @@ const mainNavItems = computed(() => { }, ]; - if (page.props.auth.user.is_admin) { + if (page.props.auth.permissions.globalAdmin) { items.push({ title: 'Users', href: adminUsers(), icon: Shield, }); + } + if (page.props.auth.permissions.canManageTeams) { items.push({ title: 'Teams', - href: adminTeams(), + href: teamsIndex(), icon: Users, }); } diff --git a/resources/js/pages/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue index a40a59f..3fab43e 100644 --- a/resources/js/pages/admin/users/Index.vue +++ b/resources/js/pages/admin/users/Index.vue @@ -1,6 +1,6 @@