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:
Dan Brown 2023-01-23 15:09:03 +00:00
parent f3f2a0c1d5
commit 91e613fe60
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
5 changed files with 155 additions and 172 deletions

View File

@ -3,36 +3,36 @@
namespace BookStack\Auth\Permissions; namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator class EntityPermissionEvaluator
{ {
protected Entity $entity;
protected array $userRoleIds;
protected string $action; 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; $this->action = $action;
} }
public function evaluate(): ?bool public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
{ {
if ($this->isUserSystemAdmin()) { if ($this->isUserSystemAdmin($userRoleIds)) {
return true; return true;
} }
$typeIdChain = $this->gatherEntityChainTypeIds(); $typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
$relevantPermissions = $this->getRelevantPermissionsMapByTypeId($typeIdChain); $relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions); $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 // Return grant or reject from role-level if exists
if (count($permitsByType['role']) > 0) { if (count($permitsByType['role']) > 0) {
return boolval(max($permitsByType['role'])); return boolval(max($permitsByType['role']));
@ -73,10 +73,9 @@ class EntityPermissionEvaluator
* @param string[] $typeIdChain * @param string[] $typeIdChain
* @return array<string, EntityPermission[]> * @return array<string, EntityPermission[]>
*/ */
protected function getRelevantPermissionsMapByTypeId(array $typeIdChain): array protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{ {
$relevantPermissions = EntityPermission::query() $query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) { foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) { $query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId); [$type, $id] = explode(':', $typeId);
@ -84,10 +83,15 @@ class EntityPermissionEvaluator
->where('entity_id', '=', $id); ->where('entity_id', '=', $id);
}); });
} }
})->where(function (Builder $query) { });
$query->whereIn('role_id', [...$this->userRoleIds, 0]);
})->get(['entity_id', 'entity_type', 'role_id', $this->action]) if (!empty($filterRoleIds)) {
->all(); $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 = []; $map = [];
foreach ($relevantPermissions as $permission) { foreach ($relevantPermissions as $permission) {
@ -105,27 +109,27 @@ class EntityPermissionEvaluator
/** /**
* @return string[] * @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 // 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. // 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) { if ($entity->type === 'page' && $entity->chapter_id) {
$chain[] = 'chapter:' . $this->entity->chapter_id; $chain[] = 'chapter:' . $entity->chapter_id;
} }
if ($this->entity instanceof Page || $this->entity instanceof Chapter) { if ($entity->type === 'page' || $entity->type === 'chapter') {
$chain[] = 'book:' . $this->entity->book_id; $chain[] = 'book:' . $entity->book_id;
} }
return $chain; return $chain;
} }
protected function isUserSystemAdmin(): bool protected function isUserSystemAdmin($userRoleIds): bool
{ {
$adminRoleId = Role::getSystemRole('admin')->id; $adminRoleId = Role::getSystemRole('admin')->id;
return in_array($adminRoleId, $this->userRoleIds); return in_array($adminRoleId, $userRoleIds);
} }
} }

View File

@ -19,11 +19,6 @@ use Illuminate\Support\Facades\DB;
*/ */
class JointPermissionBuilder class JointPermissionBuilder
{ {
/**
* @var array<string, array<int, SimpleEntityData>>
*/
protected array $entityCache;
/** /**
* Re-generate all entity permission from scratch. * 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. * Get a query for fetching a book with its children.
*/ */
@ -214,13 +175,7 @@ class JointPermissionBuilder
$simpleEntities = []; $simpleEntities = [];
foreach ($entities as $entity) { foreach ($entities as $entity) {
$attrs = $entity->getAttributes(); $simple = SimpleEntityData::fromEntity($entity);
$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;
$simpleEntities[] = $simple; $simpleEntities[] = $simple;
} }
@ -236,18 +191,10 @@ class JointPermissionBuilder
protected function createManyJointPermissions(array $originalEntities, array $roles) protected function createManyJointPermissions(array $originalEntities, array $roles)
{ {
$entities = $this->entitiesToSimpleEntities($originalEntities); $entities = $this->entitiesToSimpleEntities($originalEntities);
$this->readyEntityCache($entities);
$jointPermissions = []; $jointPermissions = [];
// Fetch related entity permissions // Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities); $permissions = new MassEntityPermissionEvaluator($entities, 'view');
// 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;
}
// Create a mapping of role permissions // Create a mapping of role permissions
$rolePermissionMap = []; $rolePermissionMap = [];
@ -260,13 +207,14 @@ class JointPermissionBuilder
// Create Joint Permission Data // Create Joint Permission Data
foreach ($entities as $entity) { foreach ($entities as $entity) {
foreach ($roles as $role) { foreach ($roles as $role) {
$jointPermissions[] = $this->createJointPermissionData( $jp = $this->createJointPermissionData(
$entity, $entity,
$role->getRawAttribute('id'), $role->getRawAttribute('id'),
$permissionMap, $permissions,
$rolePermissionMap, $rolePermissionMap,
$role->system_name === 'admin' $role->system_name === 'admin'
); );
$jointPermissions[] = $jp;
} }
} }
@ -300,96 +248,30 @@ class JointPermissionBuilder
return $idsByType; 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 * Create entity permission data for an entity and role
* for a particular action. * 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'; // Ensure system admin role retains permissions
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
if ($isAdminRole) { if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, true, true); return $this->createJointPermissionDataArray($entity, $roleId, true, true);
} }
if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) { // Return evaluated entity permission status if it has an affect.
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId); $entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
if ($entityPermissionStatus !== null) {
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess); return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, $entityPermissionStatus);
} }
if ($entity->type === 'book' || $entity->type === 'bookshelf') { // 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); 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;
}
/** /**
* Create an array of data with the information of an entity jointPermissions. * Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion. * Used to build data for bulk insertion.

View File

@ -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;
}
}

View File

@ -47,7 +47,7 @@ class PermissionApplicator
return $hasRolePermission; return $hasRolePermission;
} }
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $user->id, $userRoleIds, $action); $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions; 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. * 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. * 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); $this->ensureValidEntityAction($action);
return (new EntityPermissionEvaluator($entity, $userId, $userRoleIds, $action))->evaluate(); return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
} }
/** /**

View File

@ -2,6 +2,8 @@
namespace BookStack\Auth\Permissions; namespace BookStack\Auth\Permissions;
use BookStack\Entities\Models\Entity;
class SimpleEntityData class SimpleEntityData
{ {
public int $id; public int $id;
@ -9,4 +11,18 @@ class SimpleEntityData
public int $owned_by; public int $owned_by;
public ?int $book_id; public ?int $book_id;
public ?int $chapter_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;
}
} }