1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-07-30 04:23:11 +03:00

Started new permission-caching/querying model

This commit is contained in:
Dan Brown
2022-12-22 15:09:17 +00:00
parent 2d9d2bba80
commit 39acbeac68
6 changed files with 98 additions and 268 deletions

View File

@ -0,0 +1,37 @@
<?php
namespace BookStack\Auth\Permissions;
class EntityPermissionMap
{
protected array $map = [];
/**
* @param EntityPermission[] $permissions
*/
public function __construct(array $permissions = [])
{
foreach ($permissions as $entityPermission) {
$this->addPermission($entityPermission);
}
}
protected function addPermission(EntityPermission $permission)
{
$entityCombinedId = $permission->entity_type . ':' . $permission->entity_id;
if (!isset($this->map[$entityCombinedId])) {
$this->map[$entityCombinedId] = [];
}
$this->map[$entityCombinedId][] = $permission;
}
/**
* @return EntityPermission[]
*/
public function getForEntity(string $typeIdString): array
{
return $this->map[$typeIdString] ?? [];
}
}

View File

@ -19,31 +19,23 @@ use Illuminate\Support\Facades\DB;
*/
class JointPermissionBuilder
{
/**
* @var array<string, array<int, SimpleEntityData>>
*/
protected array $entityCache;
/**
* Re-generate all entity permission from scratch.
*/
public function rebuildForAll()
{
JointPermission::query()->truncate();
JointUserPermission::query()->truncate();
// Get all roles (Should be the most limited dimension)
$roles = Role::query()->with('permissions')->get()->all();
DB::table('entity_permissions_collapsed')->truncate();
// Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
$this->buildJointPermissionsForBooks($books);
});
// Chunk through all bookshelves
Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
Bookshelf::query()->withTrashed()
->select(['id', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) {
$this->generateCollapsedPermissions($shelves->all());
});
}
@ -55,7 +47,7 @@ class JointPermissionBuilder
$entities = [$entity];
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
$this->buildJointPermissionsForBooks($books, true);
return;
}
@ -78,61 +70,6 @@ class JointPermissionBuilder
$this->buildJointPermissionsForEntities($entities);
}
/**
* Build the entity jointPermissions for a particular role.
*/
public function rebuildForRole(Role $role)
{
$roles = [$role];
$role->jointPermissions()->delete();
$role->load('permissions');
// Chunk through all books
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}
/**
* 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.
*/
@ -152,7 +89,7 @@ class JointPermissionBuilder
/**
* Build joint permissions for the given book and role combinations.
*/
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
protected function buildJointPermissionsForBooks(EloquentCollection $books, bool $deleteOld = false)
{
$entities = clone $books;
@ -170,7 +107,7 @@ class JointPermissionBuilder
$this->deleteManyJointPermissionsForEntities($entities->all());
}
$this->createManyJointPermissions($entities->all(), $roles);
$this->generateCollapsedPermissions($entities->all());
}
/**
@ -178,9 +115,8 @@ class JointPermissionBuilder
*/
protected function buildJointPermissionsForEntities(array $entities)
{
$roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
$this->generateCollapsedPermissions($entities);
}
/**
@ -196,11 +132,7 @@ class JointPermissionBuilder
DB::transaction(function () use ($idsByType) {
foreach ($idsByType as $type => $ids) {
foreach (array_chunk($ids, 1000) as $idChunk) {
DB::table('joint_permissions')
->where('entity_type', '=', $type)
->whereIn('entity_id', $idChunk)
->delete();
DB::table('joint_user_permissions')
DB::table('entity_permissions_collapsed')
->where('entity_type', '=', $type)
->whereIn('entity_id', $idChunk)
->delete();
@ -233,72 +165,69 @@ class JointPermissionBuilder
}
/**
* Create & Save entity jointPermissions for many entities and roles.
* Create & Save collapsed entity permissions.
*
* @param Entity[] $originalEntities
* @param Role[] $roles
*/
protected function createManyJointPermissions(array $originalEntities, array $roles)
protected function generateCollapsedPermissions(array $originalEntities)
{
$entities = $this->entitiesToSimpleEntities($originalEntities);
$this->readyEntityCache($entities);
$jointPermissions = [];
$jointUserPermissions = [];
// Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities);
// Create a mapping of explicit entity permissions
$permissionMap = [];
$controlledUserIds = [];
foreach ($permissions as $permission) {
$type = $permission->role_id ? 'role' : ($permission->user_id ? 'user' : 'fallback');
$id = $permission->role_id ?? $permission->user_id ?? '0';
$key = $permission->entity_type . ':' . $permission->entity_id . ':' . $type . ':' . $id;
if ($type === 'user') {
$controlledUserIds[$id] = true;
}
$permissionMap[$key] = $permission->view;
}
// Create a mapping of role permissions
$rolePermissionMap = [];
foreach ($roles as $role) {
foreach ($role->permissions as $permission) {
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
}
}
$permissionMap = new EntityPermissionMap($permissions);
// Create Joint Permission Data
foreach ($entities as $entity) {
foreach ($roles as $role) {
$jointPermissions[] = $this->createJointPermissionData(
$entity,
$role->getRawAttribute('id'),
$permissionMap,
$rolePermissionMap,
$role->system_name === 'admin'
);
}
foreach ($controlledUserIds as $userId => $exists) {
$userPermitted = $this->getUserPermissionOverrideStatus($entity, $userId, $permissionMap);
if ($userPermitted !== null) {
$jointUserPermissions[] = $this->createJointUserPermissionDataArray($entity, $userId, $userPermitted);
}
}
array_push($jointPermissions, ...$this->createCollapsedPermissionData($entity, $permissionMap));
}
DB::transaction(function () use ($jointPermissions) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
DB::table('joint_permissions')->insert($jointPermissionChunk);
DB::table('entity_permissions_collapsed')->insert($jointPermissionChunk);
}
});
}
DB::transaction(function () use ($jointUserPermissions) {
foreach (array_chunk($jointUserPermissions, 1000) as $jointUserPermissionsChunk) {
DB::table('joint_user_permissions')->insert($jointUserPermissionsChunk);
/**
* Create collapsed permission data for the given entity using the given permission map.
*/
protected function createCollapsedPermissionData(SimpleEntityData $entity, EntityPermissionMap $permissionMap): array
{
$chain = [
$entity->type . ':' . $entity->id,
$entity->chapter_id ? null : ('chapter:' . $entity->chapter_id),
$entity->book_id ? null : ('book:' . $entity->book_id),
];
$permissionData = [];
$overridesApplied = [];
foreach ($chain as $entityTypeId) {
if ($entityTypeId === null) {
continue;
}
});
$permissions = $permissionMap->getForEntity($entityTypeId);
foreach ($permissions as $permission) {
$related = $permission->getAssignedType() . ':' . $permission->getAssignedTypeId();
if (!isset($overridesApplied[$related])) {
$permissionData[] = [
'role_id' => $permission->role_id,
'user_id' => $permission->user_id,
'view' => $permission->view,
'entity_type' => $entity->type,
'entity_id' => $entity->id,
];
$overridesApplied[$related] = true;
}
}
}
return $permissionData;
}
/**
@ -345,136 +274,4 @@ class JointPermissionBuilder
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
{
$permissionPrefix = $entity->type . '-view';
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
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);
}
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))
);
}
/**
* Get the status of a user-specific permission override for the given entity user combo if existing.
* This can return null where no user-specific permission overrides are applicable.
*/
protected function getUserPermissionOverrideStatus(SimpleEntityData $entity, int $userId, array $permissionMap): ?bool
{
// If direct permissions exists, return those
$directKey = $entity->type . ':' . $entity->id . ':user:' . $userId;
if (isset($permissionMap[$directKey])) {
return $permissionMap[$directKey];
}
// If a book or shelf, exit out since no parents to check
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
return null;
}
// If a chapter or page, get the parent book permission status.
// defaults to null where no permission is set.
$bookKey = 'book:' . $entity->book_id . ':user:' . $userId;
$bookPermission = $permissionMap[$bookKey] ?? null;
// If a page within a chapter, return the chapter permission if existing otherwise
// default ot the parent book permission.
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
$chapterKey = 'chapter:' . $entity->chapter_id . ':user:' . $userId;
$chapterPermission = $permissionMap[$chapterKey] ?? null;
return $chapterPermission ?? $bookPermission;
}
// Return the book permission status
return $bookPermission;
}
/**
* 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 . 'role:' . $roleId]) || isset($permissionMap[$keyPrefix . 'fallback:0']);
}
/**
* Check for an active restriction in an entity map.
*/
protected function mapHasActiveRestriction(array $permissionMap, SimpleEntityData $entity, int $roleId): bool
{
$roleKey = $entity->type . ':' . $entity->id . ':role:' . $roleId;
$defaultKey = $entity->type . ':' . $entity->id . ':fallback:0';
return $permissionMap[$roleKey] ?? $permissionMap[$defaultKey] ?? false;
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
*/
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, bool $permissionAll, bool $permissionOwn): array
{
return [
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->owned_by,
'role_id' => $roleId,
];
}
/**
* Create an array of data with the information of an JointUserPermission.
* Used to build data for bulk insertion.
*/
protected function createJointUserPermissionDataArray(SimpleEntityData $entity, int $userId, bool $hasPermission): array
{
return [
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'has_permission' => $hasPermission,
'user_id' => $userId,
];
}
}

View File

@ -57,7 +57,6 @@ class PermissionsRepo
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
$this->permissionBuilder->rebuildForRole($role);
Activity::add(ActivityType::ROLE_CREATE, $role);
@ -88,7 +87,6 @@ class PermissionsRepo
$role->fill($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$this->permissionBuilder->rebuildForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
}