]> BookStack Code Mirror - bookstack/commitdiff
Started new permission-caching/querying model
authorDan Brown <redacted>
Thu, 22 Dec 2022 15:09:17 +0000 (15:09 +0000)
committerDan Brown <redacted>
Thu, 22 Dec 2022 15:09:17 +0000 (15:09 +0000)
app/Auth/Permissions/EntityPermissionMap.php [new file with mode: 0644]
app/Auth/Permissions/JointPermissionBuilder.php
app/Auth/Permissions/PermissionsRepo.php
database/migrations/2022_12_22_103318_create_collapsed_role_permissions_table.php [moved from database/migrations/2022_12_11_104506_create_joint_user_permissions_table.php with 50% similarity]
tests/Helpers/PermissionsProvider.php
tests/PublicActionTest.php

diff --git a/app/Auth/Permissions/EntityPermissionMap.php b/app/Auth/Permissions/EntityPermissionMap.php
new file mode 100644 (file)
index 0000000..8fd2367
--- /dev/null
@@ -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] ?? [];
+    }
+}
index dbef78574abbabc5dd8efb36bb1b7ddd7a70636a..4d8692aab6d296ac1519605a9e86ddd4b46f90c1 100644 (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,
-        ];
-    }
 }
index 6dcef72568343d550c169a4ad7124a035c0223ed..01a477a59e872b1561d44b1c1eae38f98a4dfd12 100644 (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);
     }
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 f9ce2f6d81b60e177df003515929bfc66378bf24..748a8809dd92fe236bceb70b26e5fb3421464e71 100644 (file)
@@ -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');
     }
 }
index bf14ac2dae4880fc1935ca1ec77cff2e078bf644..bebb5bada9af1c1fb815b7751c8810bef4d0fbe2 100644 (file)
@@ -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();
index aa2cd636c3324dffa82421e8ec2f06afbf60b60b..7df5bd42261ea7dd59fb1f4ca098d44adbfab9fa 100644 (file)
@@ -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();