Shared entity permission logic across both query methods
The runtime userCan() and the JointPermissionBuilder now share much of the same logic for handling entity permission resolution.
This commit is contained in:
parent
f3f2a0c1d5
commit
91e613fe60
|
@ -3,36 +3,36 @@
|
|||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class EntityPermissionEvaluator
|
||||
{
|
||||
protected Entity $entity;
|
||||
protected array $userRoleIds;
|
||||
protected string $action;
|
||||
protected int $userId;
|
||||
|
||||
public function __construct(Entity $entity, int $userId, array $userRoleIds, string $action)
|
||||
public function __construct(string $action)
|
||||
{
|
||||
$this->entity = $entity;
|
||||
$this->userId = $userId;
|
||||
$this->userRoleIds = $userRoleIds;
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
public function evaluate(): ?bool
|
||||
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
|
||||
{
|
||||
if ($this->isUserSystemAdmin()) {
|
||||
if ($this->isUserSystemAdmin($userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$typeIdChain = $this->gatherEntityChainTypeIds();
|
||||
$relevantPermissions = $this->getRelevantPermissionsMapByTypeId($typeIdChain);
|
||||
$typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
|
||||
$relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
|
||||
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
|
||||
|
||||
return $this->evaluatePermitsByType($permitsByType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, int>> $permitsByType
|
||||
*/
|
||||
protected function evaluatePermitsByType(array $permitsByType): ?bool
|
||||
{
|
||||
// Return grant or reject from role-level if exists
|
||||
if (count($permitsByType['role']) > 0) {
|
||||
return boolval(max($permitsByType['role']));
|
||||
|
@ -73,21 +73,25 @@ class EntityPermissionEvaluator
|
|||
* @param string[] $typeIdChain
|
||||
* @return array<string, EntityPermission[]>
|
||||
*/
|
||||
protected function getRelevantPermissionsMapByTypeId(array $typeIdChain): array
|
||||
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): 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();
|
||||
$query = 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!empty($filterRoleIds)) {
|
||||
$query->where(function (Builder $query) use ($filterRoleIds) {
|
||||
$query->whereIn('role_id', [...$filterRoleIds, 0]);
|
||||
});
|
||||
}
|
||||
|
||||
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
|
||||
|
||||
$map = [];
|
||||
foreach ($relevantPermissions as $permission) {
|
||||
|
@ -105,27 +109,27 @@ class EntityPermissionEvaluator
|
|||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
protected function gatherEntityChainTypeIds(): array
|
||||
protected function gatherEntityChainTypeIds(SimpleEntityData $entity): 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];
|
||||
$chain = [$entity->type . ':' . $entity->id];
|
||||
|
||||
if ($this->entity instanceof Page && $this->entity->chapter_id) {
|
||||
$chain[] = 'chapter:' . $this->entity->chapter_id;
|
||||
if ($entity->type === 'page' && $entity->chapter_id) {
|
||||
$chain[] = 'chapter:' . $entity->chapter_id;
|
||||
}
|
||||
|
||||
if ($this->entity instanceof Page || $this->entity instanceof Chapter) {
|
||||
$chain[] = 'book:' . $this->entity->book_id;
|
||||
if ($entity->type === 'page' || $entity->type === 'chapter') {
|
||||
$chain[] = 'book:' . $entity->book_id;
|
||||
}
|
||||
|
||||
return $chain;
|
||||
}
|
||||
|
||||
protected function isUserSystemAdmin(): bool
|
||||
protected function isUserSystemAdmin($userRoleIds): bool
|
||||
{
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
return in_array($adminRoleId, $this->userRoleIds);
|
||||
return in_array($adminRoleId, $userRoleIds);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,11 +19,6 @@ use Illuminate\Support\Facades\DB;
|
|||
*/
|
||||
class JointPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<int, SimpleEntityData>>
|
||||
*/
|
||||
protected array $entityCache;
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
*/
|
||||
|
@ -98,40 +93,6 @@ class JointPermissionBuilder
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the local entity cache and ensure it's empty.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*/
|
||||
protected function readyEntityCache(array $entities)
|
||||
{
|
||||
$this->entityCache = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($this->entityCache[$entity->type])) {
|
||||
$this->entityCache[$entity->type] = [];
|
||||
}
|
||||
|
||||
$this->entityCache[$entity->type][$entity->id] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a book via ID, Checks local cache.
|
||||
*/
|
||||
protected function getBook(int $bookId): SimpleEntityData
|
||||
{
|
||||
return $this->entityCache['book'][$bookId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chapter via ID, Checks local cache.
|
||||
*/
|
||||
protected function getChapter(int $chapterId): SimpleEntityData
|
||||
{
|
||||
return $this->entityCache['chapter'][$chapterId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
*/
|
||||
|
@ -214,13 +175,7 @@ class JointPermissionBuilder
|
|||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new SimpleEntityData();
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
$simple = SimpleEntityData::fromEntity($entity);
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
|
@ -236,18 +191,10 @@ class JointPermissionBuilder
|
|||
protected function createManyJointPermissions(array $originalEntities, array $roles)
|
||||
{
|
||||
$entities = $this->entitiesToSimpleEntities($originalEntities);
|
||||
$this->readyEntityCache($entities);
|
||||
$jointPermissions = [];
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id;
|
||||
$permissionMap[$key] = $permission->view;
|
||||
}
|
||||
$permissions = new MassEntityPermissionEvaluator($entities, 'view');
|
||||
|
||||
// Create a mapping of role permissions
|
||||
$rolePermissionMap = [];
|
||||
|
@ -260,13 +207,14 @@ class JointPermissionBuilder
|
|||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
foreach ($roles as $role) {
|
||||
$jointPermissions[] = $this->createJointPermissionData(
|
||||
$jp = $this->createJointPermissionData(
|
||||
$entity,
|
||||
$role->getRawAttribute('id'),
|
||||
$permissionMap,
|
||||
$permissions,
|
||||
$rolePermissionMap,
|
||||
$role->system_name === 'admin'
|
||||
);
|
||||
$jointPermissions[] = $jp;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -300,94 +248,28 @@ class JointPermissionBuilder
|
|||
return $idsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions for all the given entities.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
protected function getEntityPermissionsForEntities(array $entities): array
|
||||
{
|
||||
$idsByType = $this->entitiesToTypeIdMap($entities);
|
||||
$permissionFetch = EntityPermission::query()
|
||||
->where(function (Builder $query) use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $permissionFetch->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity permission data for an entity and role
|
||||
* for a particular action.
|
||||
*/
|
||||
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
|
||||
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
|
||||
{
|
||||
$permissionPrefix = $entity->type . '-view';
|
||||
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
|
||||
|
||||
// Ensure system admin role retains permissions
|
||||
if ($isAdminRole) {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
|
||||
}
|
||||
|
||||
if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) {
|
||||
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
|
||||
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
|
||||
// Return evaluated entity permission status if it has an affect.
|
||||
$entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
|
||||
if ($entityPermissionStatus !== null) {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, $entityPermissionStatus);
|
||||
}
|
||||
|
||||
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
|
||||
}
|
||||
|
||||
// For chapters and pages, Check if explicit permissions are set on the Book.
|
||||
$book = $this->getBook($entity->book_id);
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
|
||||
$hasPermissiveAccessToParents = !$this->entityPermissionsActiveForRole($permissionMap, $book, $roleId);
|
||||
|
||||
// For pages with a chapter, Check if explicit permissions are set on the Chapter
|
||||
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
|
||||
$chapter = $this->getChapter($entity->chapter_id);
|
||||
$chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted;
|
||||
if ($chapterRestricted) {
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createJointPermissionDataArray(
|
||||
$entity,
|
||||
$roleId,
|
||||
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
|
||||
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity permissions are defined within the given map, for the given entity and role.
|
||||
* Checks for the default `role_id=0` backup option as a fallback.
|
||||
*/
|
||||
protected function entityPermissionsActiveForRole(array $permissionMap, SimpleEntityData $entity, int $roleId): bool
|
||||
{
|
||||
$keyPrefix = $entity->type . ':' . $entity->id . ':';
|
||||
return isset($permissionMap[$keyPrefix . $roleId]) || isset($permissionMap[$keyPrefix . '0']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an active restriction in an entity map.
|
||||
*/
|
||||
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
|
||||
{
|
||||
$roleKey = $entity->type . ':' . $entity->id . ':' . $roleId;
|
||||
$defaultKey = $entity->type . ':' . $entity->id . ':0';
|
||||
|
||||
return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? false;
|
||||
// Otherwise default to the role-level permissions
|
||||
$permissionPrefix = $entity->type . '-view';
|
||||
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class MassEntityPermissionEvaluator extends EntityPermissionEvaluator
|
||||
{
|
||||
/**
|
||||
* @var SimpleEntityData[]
|
||||
*/
|
||||
protected array $entitiesInvolved;
|
||||
protected array $permissionMapCache;
|
||||
|
||||
public function __construct(array $entitiesInvolved, string $action)
|
||||
{
|
||||
$this->entitiesInvolved = $entitiesInvolved;
|
||||
parent::__construct($action);
|
||||
}
|
||||
|
||||
public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?bool
|
||||
{
|
||||
$typeIdChain = $this->gatherEntityChainTypeIds($entity);
|
||||
$relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId);
|
||||
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
|
||||
|
||||
return $this->evaluatePermitsByType($permitsByType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $typeIdChain
|
||||
* @return array<string, EntityPermission[]>
|
||||
*/
|
||||
protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array
|
||||
{
|
||||
$allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved();
|
||||
$relevantPermissions = [];
|
||||
|
||||
// Filter down permissions to just those for current typeId
|
||||
// and current roleID or fallback permissions.
|
||||
foreach ($typeIdChain as $typeId) {
|
||||
$relevantPermissions[$typeId] = [
|
||||
...($allPermissions[$typeId][$roleId] ?? []),
|
||||
...($allPermissions[$typeId][0] ?? [])
|
||||
];
|
||||
}
|
||||
|
||||
return $relevantPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, EntityPermission[]>>
|
||||
*/
|
||||
protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array
|
||||
{
|
||||
if (isset($this->permissionMapCache)) {
|
||||
return $this->permissionMapCache;
|
||||
}
|
||||
|
||||
$entityTypeIdChain = [];
|
||||
foreach ($this->entitiesInvolved as $entity) {
|
||||
$entityTypeIdChain[] = $entity->type . ':' . $entity->id;
|
||||
}
|
||||
|
||||
$permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []);
|
||||
|
||||
// Manipulate permission map to also be keyed by roleId.
|
||||
foreach ($permissionMap as $typeId => $permissions) {
|
||||
$permissionMap[$typeId] = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$roleId = $permission->getRawAttribute('role_id');
|
||||
if (!isset($permissionMap[$typeId][$roleId])) {
|
||||
$permissionMap[$typeId][$roleId] = [];
|
||||
}
|
||||
$permissionMap[$typeId][$roleId][] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissionMapCache = $permissionMap;
|
||||
|
||||
return $this->permissionMapCache;
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ class PermissionApplicator
|
|||
return $hasRolePermission;
|
||||
}
|
||||
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $user->id, $userRoleIds, $action);
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
|
||||
|
||||
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
|
||||
}
|
||||
|
@ -56,11 +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, int $userId, array $userRoleIds, string $action): ?bool
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
|
||||
{
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
return (new EntityPermissionEvaluator($entity, $userId, $userRoleIds, $action))->evaluate();
|
||||
return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
|
@ -9,4 +11,18 @@ class SimpleEntityData
|
|||
public int $owned_by;
|
||||
public ?int $book_id;
|
||||
public ?int $chapter_id;
|
||||
|
||||
public static function fromEntity(Entity $entity): self
|
||||
{
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new self();
|
||||
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
|
||||
return $simple;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue