permissions refactoring
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Permissions;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Models\PermissionAssignment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PermissionService
|
||||
{
|
||||
/**
|
||||
* @param array<int, Permission|string> $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<int, Permission>
|
||||
*/
|
||||
public function effectivePermissions(User $user, ?Model $scope = null): array
|
||||
{
|
||||
if ($this->hasGlobalAdmin($user)) {
|
||||
return Permission::cases();
|
||||
}
|
||||
|
||||
/** @var Collection<int, Permission> $permissions */
|
||||
$permissions = $this->relevantAssignments($user, $scope)
|
||||
->map(fn (PermissionAssignment $assignment): Permission => $assignment->permission)
|
||||
->values();
|
||||
|
||||
return $this->expandPermissions($permissions->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
public function permissionOptions(Model|string $assignee, Model|string|null $scope): array
|
||||
{
|
||||
return Permission::options(
|
||||
Permission::assignableFor($assignee, $scope),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function assignmentMap(string $assigneeClass, ?Model $scope = null): array
|
||||
{
|
||||
/** @var Model $assignee */
|
||||
$assignee = new $assigneeClass;
|
||||
|
||||
/** @var Collection<int, PermissionAssignment> $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<int, int>
|
||||
*/
|
||||
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<int, PermissionAssignment> $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<int, int>
|
||||
*/
|
||||
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<int, PermissionAssignment> $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<int, PermissionAssignment>
|
||||
*/
|
||||
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<int, Permission|string> $permissions
|
||||
* @return array<int, Permission>
|
||||
*/
|
||||
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<int, Permission> $permissions
|
||||
* @return array<int, Permission>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectPermissions;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProjectPermissionService
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const PERMISSIONS = [
|
||||
'create',
|
||||
'view',
|
||||
'edit',
|
||||
'delete',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return self::PERMISSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
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<int, string> $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<int, string> $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<int, int> $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<int, int>
|
||||
*/
|
||||
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<string, array<int, string>>
|
||||
*/
|
||||
public function userPermissionMap(Project $project): array
|
||||
{
|
||||
/** @var Collection<int, object{user_id: int, permission: string}> $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<string, array<int, string>>
|
||||
*/
|
||||
public function teamPermissionMap(Project $project): array
|
||||
{
|
||||
/** @var Collection<int, object{team_id: int, permission: string}> $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<int, string> $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
enum Permission: string
|
||||
{
|
||||
case GlobalAdmin = 'global:admin';
|
||||
case ProjectCreate = 'project:create';
|
||||
case ProjectRead = 'project:read';
|
||||
case ProjectWrite = 'project:write';
|
||||
case ProjectAdmin = 'project:admin';
|
||||
case TeamInvite = 'team:invite';
|
||||
case TeamManage = 'team:manage';
|
||||
case TeamAdmin = 'team:admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::GlobalAdmin => '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<int, self>
|
||||
*/
|
||||
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<int, self>|null $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(?array $permissions = null): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $permission): string => $permission->value,
|
||||
$permissions ?? self::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function globalAssignable(): array
|
||||
{
|
||||
return [
|
||||
self::GlobalAdmin,
|
||||
self::ProjectCreate,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function projectAssignable(): array
|
||||
{
|
||||
return [
|
||||
self::ProjectRead,
|
||||
self::ProjectWrite,
|
||||
self::ProjectAdmin,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function teamAssignable(): array
|
||||
{
|
||||
return [
|
||||
self::TeamInvite,
|
||||
self::TeamManage,
|
||||
self::TeamAdmin,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, self>
|
||||
*/
|
||||
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<int, self> $permissions
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
public static function options(array $permissions): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $permission): array => [
|
||||
'label' => $permission->label(),
|
||||
'value' => $permission->value,
|
||||
],
|
||||
$permissions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PermissionScope: string
|
||||
{
|
||||
case Global = 'global';
|
||||
case Project = 'project';
|
||||
case Team = 'team';
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateUserAdminStatusRequest;
|
||||
use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -12,28 +13,30 @@ use Inertia\Response;
|
||||
|
||||
class UserManagementController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/users/Index', [
|
||||
'directPermissions' => $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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
+9
-2
@@ -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())),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SyncTeamUserPermissionsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'permissions' => ['nullable', 'array'],
|
||||
'permissions.*' => [
|
||||
'string',
|
||||
'distinct',
|
||||
Rule::in(Permission::values(Permission::teamAssignable())),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class UpdateTeamMembersRequest extends FormRequest
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Database\Factories\PermissionAssignmentFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
#[Fillable(['permission', 'assignee_type', 'assignee_id', 'scope_type', 'scope_id'])]
|
||||
class PermissionAssignment extends Model
|
||||
{
|
||||
/** @use HasFactory<PermissionAssignmentFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'permission' => Permission::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function assignee(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function scope(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
@@ -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<ProjectFactory> */
|
||||
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)
|
||||
|
||||
@@ -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<TeamFactory> */
|
||||
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)
|
||||
|
||||
+13
-1
@@ -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<UserFactory> */
|
||||
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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user