permissions refactoring

This commit is contained in:
2026-04-12 19:21:04 +02:00
parent 51014395a2
commit 9dcdfb981a
64 changed files with 1842 additions and 656 deletions
@@ -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();
}
}