diff --git a/app/Auth/Permissions/EntityPermissionEvaluator.php b/app/Auth/Permissions/EntityPermissionEvaluator.php new file mode 100644 index 000000000..91596d02a --- /dev/null +++ b/app/Auth/Permissions/EntityPermissionEvaluator.php @@ -0,0 +1,131 @@ +entity = $entity; + $this->userId = $userId; + $this->userRoleIds = $userRoleIds; + $this->action = $action; + } + + public function evaluate(): ?bool + { + if ($this->isUserSystemAdmin()) { + return true; + } + + $typeIdChain = $this->gatherEntityChainTypeIds(); + $relevantPermissions = $this->getRelevantPermissionsMapByTypeId($typeIdChain); + $permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions); + + // Return grant or reject from role-level if exists + if (count($permitsByType['role']) > 0) { + return boolval(max($permitsByType['role'])); + } + + // Return fallback permission if exists + if (count($permitsByType['fallback']) > 0) { + return boolval($permitsByType['fallback'][0]); + } + + return null; + } + + /** + * @param string[] $typeIdChain + * @param array $permissionMapByTypeId + * @return array> + */ + protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array + { + $permitsByType = ['fallback' => [], 'role' => []]; + + foreach ($typeIdChain as $typeId) { + $permissions = $permissionMapByTypeId[$typeId] ?? []; + foreach ($permissions as $permission) { + $roleId = $permission->role_id; + $type = $roleId === 0 ? 'fallback' : 'role'; + if (!isset($permitsByType[$type][$roleId])) { + $permitsByType[$type][$roleId] = $permission->{$this->action}; + } + } + } + + return $permitsByType; + } + + /** + * @param string[] $typeIdChain + * @return array + */ + protected function getRelevantPermissionsMapByTypeId(array $typeIdChain): array + { + $relevantPermissions = EntityPermission::query() + ->where(function (Builder $query) use ($typeIdChain) { + foreach ($typeIdChain as $typeId) { + $query->orWhere(function (Builder $query) use ($typeId) { + [$type, $id] = explode(':', $typeId); + $query->where('entity_type', '=', $type) + ->where('entity_id', '=', $id); + }); + } + })->where(function (Builder $query) { + $query->whereIn('role_id', [...$this->userRoleIds, 0]); + })->get(['entity_id', 'entity_type', 'role_id', $this->action]) + ->all(); + + $map = []; + foreach ($relevantPermissions as $permission) { + $key = $permission->entity_type . ':' . $permission->entity_id; + if (!isset($map[$key])) { + $map[$key] = []; + } + + $map[$key][] = $permission; + } + + return $map; + } + + /** + * @return string[] + */ + protected function gatherEntityChainTypeIds(): array + { + // The array order here is very important due to the fact we walk up the chain + // elsewhere in the class. Earlier items in the chain have higher priority. + + $chain = [$this->entity->getMorphClass() . ':' . $this->entity->id]; + + if ($this->entity instanceof Page && $this->entity->chapter_id) { + $chain[] = 'chapter:' . $this->entity->chapter_id; + } + + if ($this->entity instanceof Page || $this->entity instanceof Chapter) { + $chain[] = 'book:' . $this->entity->book_id; + } + + return $chain; + } + + protected function isUserSystemAdmin(): bool + { + $adminRoleId = Role::getSystemRole('admin')->id; + return in_array($adminRoleId, $this->userRoleIds); + } +} diff --git a/app/Auth/Permissions/PermissionApplicator.php b/app/Auth/Permissions/PermissionApplicator.php index af372cb74..3855a283b 100644 --- a/app/Auth/Permissions/PermissionApplicator.php +++ b/app/Auth/Permissions/PermissionApplicator.php @@ -4,7 +4,6 @@ namespace BookStack\Auth\Permissions; use BookStack\Auth\Role; use BookStack\Auth\User; -use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Model; @@ -48,7 +47,7 @@ class PermissionApplicator return $hasRolePermission; } - $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action); + $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $user->id, $userRoleIds, $action); return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions; } @@ -57,50 +56,11 @@ class PermissionApplicator * Check if there are permissions that are applicable for the given entity item, action and roles. * Returns null when no entity permissions are in force. */ - protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool + protected function hasEntityPermission(Entity $entity, int $userId, array $userRoleIds, string $action): ?bool { $this->ensureValidEntityAction($action); - $adminRoleId = Role::getSystemRole('admin')->id; - if (in_array($adminRoleId, $userRoleIds)) { - return true; - } - - // The chain order here is very important due to the fact we walk up the chain - // in the loop below. Earlier items in the chain have higher priority. - $chain = [$entity]; - if ($entity instanceof Page && $entity->chapter_id) { - $chain[] = $entity->chapter; - } - - if ($entity instanceof Page || $entity instanceof Chapter) { - $chain[] = $entity->book; - } - - foreach ($chain as $currentEntity) { - $allowedByRoleId = $currentEntity->permissions() - ->whereIn('role_id', [0, ...$userRoleIds]) - ->pluck($action, 'role_id'); - - // Continue up the chain if no applicable entity permission overrides. - if ($allowedByRoleId->isEmpty()) { - continue; - } - - // If we have user-role-specific permissions set, allow if any of those - // role permissions allow access. - $hasDefault = $allowedByRoleId->has(0); - if (!$hasDefault || $allowedByRoleId->count() > 1) { - return $allowedByRoleId->search(function (bool $allowed, int $roleId) { - return $roleId !== 0 && $allowed; - }) !== false; - } - - // Otherwise, return the default "Other roles" fallback value. - return $allowedByRoleId->get(0); - } - - return null; + return (new EntityPermissionEvaluator($entity, $userId, $userRoleIds, $action))->evaluate(); } /**