From: Dan Brown Date: Thu, 22 Dec 2022 15:09:17 +0000 (+0000) Subject: Started new permission-caching/querying model X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/39acbeac6856145b7e480d1c0fbcf2dee68d9ee6 Started new permission-caching/querying model --- diff --git a/app/Auth/Permissions/EntityPermissionMap.php b/app/Auth/Permissions/EntityPermissionMap.php new file mode 100644 index 000000000..8fd236734 --- /dev/null +++ b/app/Auth/Permissions/EntityPermissionMap.php @@ -0,0 +1,37 @@ +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] ?? []; + } +} diff --git a/app/Auth/Permissions/JointPermissionBuilder.php b/app/Auth/Permissions/JointPermissionBuilder.php index dbef78574..4d8692aab 100644 --- a/app/Auth/Permissions/JointPermissionBuilder.php +++ b/app/Auth/Permissions/JointPermissionBuilder.php @@ -19,31 +19,23 @@ use Illuminate\Support\Facades\DB; */ class JointPermissionBuilder { - /** - * @var array> - */ - 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, - ]; - } } diff --git a/app/Auth/Permissions/PermissionsRepo.php b/app/Auth/Permissions/PermissionsRepo.php index 6dcef7256..01a477a59 100644 --- a/app/Auth/Permissions/PermissionsRepo.php +++ b/app/Auth/Permissions/PermissionsRepo.php @@ -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); } diff --git a/database/migrations/2022_12_11_104506_create_joint_user_permissions_table.php b/database/migrations/2022_12_22_103318_create_collapsed_role_permissions_table.php similarity index 50% rename from database/migrations/2022_12_11_104506_create_joint_user_permissions_table.php rename to database/migrations/2022_12_22_103318_create_collapsed_role_permissions_table.php index f9ce2f6d8..748a8809d 100644 --- a/database/migrations/2022_12_11_104506_create_joint_user_permissions_table.php +++ b/database/migrations/2022_12_22_103318_create_collapsed_role_permissions_table.php @@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class CreateJointUserPermissionsTable extends Migration +class CreateCollapsedRolePermissionsTable extends Migration { /** * Run the migrations. @@ -13,13 +13,15 @@ class CreateJointUserPermissionsTable extends Migration */ public function up() { - Schema::create('joint_user_permissions', function (Blueprint $table) { - $table->unsignedInteger('user_id'); + Schema::create('entity_permissions_collapsed', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('role_id')->nullable(); + $table->unsignedInteger('user_id')->nullable(); $table->string('entity_type'); $table->unsignedInteger('entity_id'); - $table->boolean('has_permission')->index(); + $table->boolean('view')->index(); - $table->primary(['user_id', 'entity_type', 'entity_id']); + $table->index(['entity_type', 'entity_id']); }); } @@ -30,6 +32,6 @@ class CreateJointUserPermissionsTable extends Migration */ public function down() { - Schema::dropIfExists('joint_user_permissions'); + Schema::dropIfExists('entity_permissions_collapsed'); } } diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php index bf14ac2da..bebb5bada 100644 --- a/tests/Helpers/PermissionsProvider.php +++ b/tests/Helpers/PermissionsProvider.php @@ -34,8 +34,6 @@ class PermissionsProvider */ public function removeUserRolePermissions(User $user, array $permissions): void { - $permissionBuilder = app()->make(JointPermissionBuilder::class); - foreach ($permissions as $permissionName) { /** @var RolePermission $permission */ $permission = RolePermission::query() @@ -49,7 +47,6 @@ class PermissionsProvider /** @var Role $role */ foreach ($roles as $role) { $role->detachPermission($permission); - $permissionBuilder->rebuildForRole($role); } $user->clearPermissionCache(); diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index aa2cd636c..7df5bd422 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -89,7 +89,6 @@ class PublicActionTest extends TestCase foreach (RolePermission::all() as $perm) { $publicRole->attachPermission($perm); } - $this->app->make(JointPermissionBuilder::class)->rebuildForRole($publicRole); user()->clearPermissionCache(); $chapter = $this->entities->chapter();