380 lines
14 KiB
PHP
380 lines
14 KiB
PHP
<?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\Collection as EloquentCollection;
|
|
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),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param EloquentCollection<int, User> $users
|
|
* @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array<int, int>}
|
|
*/
|
|
public function projectAccessMap(EloquentCollection $users, Project $project): array
|
|
{
|
|
$users->loadMissing('teams');
|
|
$directPermissions = $this->assignmentMap(User::class, $project);
|
|
$teamPermissions = $this->assignmentMap(Team::class, $project);
|
|
$globalPermissions = $this->assignmentMap(User::class);
|
|
$effectivePermissions = [];
|
|
$globalAdminUserIds = [];
|
|
|
|
foreach ($users as $user) {
|
|
$permissions = $directPermissions[$user->id] ?? [];
|
|
|
|
foreach ($user->teams as $team) {
|
|
$permissions = array_merge($permissions, $teamPermissions[$team->id] ?? []);
|
|
}
|
|
|
|
if (in_array(Permission::GlobalAdmin->value, $globalPermissions[$user->id] ?? [], true)) {
|
|
$permissions[] = Permission::GlobalAdmin->value;
|
|
$globalAdminUserIds[] = $user->id;
|
|
}
|
|
|
|
$expanded = $this->expandPermissions(array_map(Permission::from(...), $permissions));
|
|
$effectivePermissions[$user->id] = Permission::values(array_values(array_filter(
|
|
Permission::projectAssignable(),
|
|
fn (Permission $permission): bool => in_array($permission, $expanded, true),
|
|
)));
|
|
}
|
|
|
|
return compact('directPermissions', 'teamPermissions', 'effectivePermissions', 'globalAdminUserIds');
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
}
|