]> BookStack Code Mirror - bookstack/commitdiff
Fixed collapsed perm. gen for book sub-items.
authorDan Brown <redacted>
Fri, 23 Dec 2022 13:56:22 +0000 (13:56 +0000)
committerDan Brown <redacted>
Fri, 23 Dec 2022 14:05:43 +0000 (14:05 +0000)
Also converted the existing "JointPermission" usage to the new
collapsed permission system.

19 files changed:
app/Auth/Permissions/CollapsedPermission.php [new file with mode: 0644]
app/Auth/Permissions/CollapsedPermissionBuilder.php [moved from app/Auth/Permissions/JointPermissionBuilder.php with 78% similarity]
app/Auth/Permissions/JointPermission.php [deleted file]
app/Auth/Permissions/JointUserPermission.php [deleted file]
app/Auth/Permissions/PermissionsRepo.php
app/Auth/Permissions/SimpleEntityData.php
app/Auth/Role.php
app/Auth/User.php
app/Auth/UserRepo.php
app/Console/Commands/RegeneratePermissions.php
app/Entities/Models/Entity.php
app/Entities/Tools/TrashCan.php
app/helpers.php
database/migrations/2022_12_22_103318_create_collapsed_role_permissions_table.php
database/seeders/DummyContentSeeder.php
database/seeders/LargeContentSeeder.php
tests/Commands/RegeneratePermissionsCommandTest.php
tests/Helpers/PermissionsProvider.php
tests/PublicActionTest.php

diff --git a/app/Auth/Permissions/CollapsedPermission.php b/app/Auth/Permissions/CollapsedPermission.php
new file mode 100644 (file)
index 0000000..7cdc9c9
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace BookStack\Auth\Permissions;
+
+use BookStack\Model;
+
+/**
+ * @property int $id
+ * @property ?int $role_id
+ * @property ?int $user_id
+ * @property string $entity_type
+ * @property int $entity_id
+ * @property bool $view
+ */
+class CollapsedPermission extends Model
+{
+    protected $table = 'entity_permissions_collapsed';
+}
similarity index 78%
rename from app/Auth/Permissions/JointPermissionBuilder.php
rename to app/Auth/Permissions/CollapsedPermissionBuilder.php
index 313f5de18638d7b33ffb7b6b10c8efdf135ccd68..69fe1f3c9ad0bcc821527d58ad89534f185a8e18 100644 (file)
@@ -13,13 +13,13 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection;
 use Illuminate\Support\Facades\DB;
 
 /**
- * Joint permissions provide a pre-query "cached" table of view permissions for all core entity
- * types for all roles in the system. This class generates out that table for different scenarios.
+ * Collapsed permissions act as a "flattened" view of entity-level permissions in the system
+ * so inheritance does not have to managed as part of permission querying.
  */
-class JointPermissionBuilder
+class CollapsedPermissionBuilder
 {
     /**
-     * Re-generate all entity permission from scratch.
+     * Re-generate all collapsed permissions from scratch.
      */
     public function rebuildForAll()
     {
@@ -27,26 +27,26 @@ class JointPermissionBuilder
 
         // Chunk through all books
         $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
-            $this->buildJointPermissionsForBooks($books);
+            $this->buildForBooks($books, false);
         });
 
         // Chunk through all bookshelves
         Bookshelf::query()->withTrashed()
-            ->select(['id', 'owned_by'])
+            ->select(['id'])
             ->chunk(50, function (EloquentCollection $shelves) {
                 $this->generateCollapsedPermissions($shelves->all());
             });
     }
 
     /**
-     * Rebuild the entity jointPermissions for a particular entity.
+     * Rebuild the collapsed permissions for a particular entity.
      */
     public function rebuildForEntity(Entity $entity)
     {
         $entities = [$entity];
         if ($entity instanceof Book) {
             $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
-            $this->buildJointPermissionsForBooks($books, true);
+            $this->buildForBooks($books, true);
 
             return;
         }
@@ -66,7 +66,7 @@ class JointPermissionBuilder
             }
         }
 
-        $this->buildJointPermissionsForEntities($entities);
+        $this->buildForEntities($entities);
     }
 
     /**
@@ -75,20 +75,20 @@ class JointPermissionBuilder
     protected function bookFetchQuery(): Builder
     {
         return Book::query()->withTrashed()
-            ->select(['id', 'owned_by'])->with([
+            ->select(['id'])->with([
                 'chapters' => function ($query) {
-                    $query->withTrashed()->select(['id', 'owned_by', 'book_id']);
+                    $query->withTrashed()->select(['id', 'book_id']);
                 },
                 'pages' => function ($query) {
-                    $query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
+                    $query->withTrashed()->select(['id', 'book_id', 'chapter_id']);
                 },
             ]);
     }
 
     /**
-     * Build joint permissions for the given book and role combinations.
+     * Build collapsed permissions for the given books.
      */
-    protected function buildJointPermissionsForBooks(EloquentCollection $books, bool $deleteOld = false)
+    protected function buildForBooks(EloquentCollection $books, bool $deleteOld)
     {
         $entities = clone $books;
 
@@ -103,27 +103,27 @@ class JointPermissionBuilder
         }
 
         if ($deleteOld) {
-            $this->deleteManyJointPermissionsForEntities($entities->all());
+            $this->deleteForEntities($entities->all());
         }
 
         $this->generateCollapsedPermissions($entities->all());
     }
 
     /**
-     * Rebuild the entity jointPermissions for a collection of entities.
+     * Rebuild the collapsed permissions for a collection of entities.
      */
-    protected function buildJointPermissionsForEntities(array $entities)
+    protected function buildForEntities(array $entities)
     {
-        $this->deleteManyJointPermissionsForEntities($entities);
+        $this->deleteForEntities($entities);
         $this->generateCollapsedPermissions($entities);
     }
 
     /**
-     * Delete all the entity jointPermissions for a list of entities.
+     * Delete the stored collapsed permissions for a list of entities.
      *
      * @param Entity[] $entities
      */
-    protected function deleteManyJointPermissionsForEntities(array $entities)
+    protected function deleteForEntities(array $entities)
     {
         $simpleEntities = $this->entitiesToSimpleEntities($entities);
         $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
@@ -141,6 +141,9 @@ class JointPermissionBuilder
     }
 
     /**
+     * Convert the given list of entities into "SimpleEntityData" representations
+     * for faster usage and property access.
+     *
      * @param Entity[] $entities
      *
      * @return SimpleEntityData[]
@@ -154,7 +157,6 @@ class JointPermissionBuilder
             $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;
@@ -171,7 +173,7 @@ class JointPermissionBuilder
     protected function generateCollapsedPermissions(array $originalEntities)
     {
         $entities = $this->entitiesToSimpleEntities($originalEntities);
-        $jointPermissions = [];
+        $collapsedPermData = [];
 
         // Fetch related entity permissions
         $permissions = $this->getEntityPermissionsForEntities($entities);
@@ -181,12 +183,12 @@ class JointPermissionBuilder
 
         // Create Joint Permission Data
         foreach ($entities as $entity) {
-            array_push($jointPermissions, ...$this->createCollapsedPermissionData($entity, $permissionMap));
+            array_push($collapsedPermData, ...$this->createCollapsedPermissionData($entity, $permissionMap));
         }
 
-        DB::transaction(function () use ($jointPermissions) {
-            foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
-                DB::table('entity_permissions_collapsed')->insert($jointPermissionChunk);
+        DB::transaction(function () use ($collapsedPermData) {
+            foreach (array_chunk($collapsedPermData, 1000) as $dataChunk) {
+                DB::table('entity_permissions_collapsed')->insert($dataChunk);
             }
         });
     }
@@ -198,8 +200,8 @@ class JointPermissionBuilder
     {
         $chain = [
             $entity->type . ':' . $entity->id,
-            $entity->chapter_id ? null : ('chapter:' . $entity->chapter_id),
-            $entity->book_id ? null : ('book:' . $entity->book_id),
+            $entity->chapter_id ? ('chapter:' . $entity->chapter_id) : null,
+            $entity->book_id ? ('book:' . $entity->book_id) : null,
         ];
 
         $permissionData = [];
diff --git a/app/Auth/Permissions/JointPermission.php b/app/Auth/Permissions/JointPermission.php
deleted file mode 100644 (file)
index e10c560..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-
-namespace BookStack\Auth\Permissions;
-
-use BookStack\Auth\Role;
-use BookStack\Entities\Models\Entity;
-use BookStack\Model;
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
-use Illuminate\Database\Eloquent\Relations\MorphOne;
-
-class JointPermission extends Model
-{
-    protected $primaryKey = null;
-    public $timestamps = false;
-
-    /**
-     * Get the role that this points to.
-     */
-    public function role(): BelongsTo
-    {
-        return $this->belongsTo(Role::class);
-    }
-
-    /**
-     * Get the entity this points to.
-     */
-    public function entity(): MorphOne
-    {
-        return $this->morphOne(Entity::class, 'entity');
-    }
-}
diff --git a/app/Auth/Permissions/JointUserPermission.php b/app/Auth/Permissions/JointUserPermission.php
deleted file mode 100644 (file)
index 2987bf5..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-
-namespace BookStack\Auth\Permissions;
-
-use BookStack\Entities\Models\Entity;
-use BookStack\Model;
-use Illuminate\Database\Eloquent\Relations\MorphOne;
-
-/**
- * Holds the "cached" user-specific permissions for entities in the system.
- * These only exist to indicate resolved permissions active via user-specific
- * entity permissions, not for all permission combinations for all users.
- *
- * @property int $user_id
- * @property int $entity_id
- * @property string $entity_type
- * @property boolean $has_permission
- */
-class JointUserPermission extends Model
-{
-    protected $primaryKey = null;
-    public $timestamps = false;
-
-    /**
-     * Get the entity this points to.
-     */
-    public function entity(): MorphOne
-    {
-        return $this->morphOne(Entity::class, 'entity');
-    }
-}
index 01a477a59e872b1561d44b1c1eae38f98a4dfd12..4162534425e0245be90d07d116aae24de68ad09d 100644 (file)
@@ -11,13 +11,13 @@ use Illuminate\Database\Eloquent\Collection;
 
 class PermissionsRepo
 {
-    protected JointPermissionBuilder $permissionBuilder;
-    protected $systemRoles = ['admin', 'public'];
+    protected CollapsedPermissionBuilder $permissionBuilder;
+    protected array $systemRoles = ['admin', 'public'];
 
     /**
      * PermissionsRepo constructor.
      */
-    public function __construct(JointPermissionBuilder $permissionBuilder)
+    public function __construct(CollapsedPermissionBuilder $permissionBuilder)
     {
         $this->permissionBuilder = $permissionBuilder;
     }
@@ -138,7 +138,7 @@ class PermissionsRepo
         }
 
         $role->entityPermissions()->delete();
-        $role->jointPermissions()->delete();
+        $role->collapsedPermissions()->delete();
         Activity::add(ActivityType::ROLE_DELETE, $role);
         $role->delete();
     }
index 62f5984f8a21274bd36bc4812b4e23ade5766ef2..75b23faaab1e6d24da03516affc23f45015c55ea 100644 (file)
@@ -6,7 +6,6 @@ class SimpleEntityData
 {
     public int $id;
     public string $type;
-    public int $owned_by;
     public ?int $book_id;
     public ?int $chapter_id;
 }
index b293d1af256aabd1d01574c4732ad729c698401a..c9dadf4fe87decb4f2fef3f630813741a1784b49 100644 (file)
@@ -2,8 +2,8 @@
 
 namespace BookStack\Auth;
 
+use BookStack\Auth\Permissions\CollapsedPermission;
 use BookStack\Auth\Permissions\EntityPermission;
-use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Interfaces\Loggable;
 use BookStack\Model;
@@ -39,14 +39,6 @@ class Role extends Model implements Loggable
         return $this->belongsToMany(User::class)->orderBy('name', 'asc');
     }
 
-    /**
-     * Get all related JointPermissions.
-     */
-    public function jointPermissions(): HasMany
-    {
-        return $this->hasMany(JointPermission::class);
-    }
-
     /**
      * The RolePermissions that belong to the role.
      */
@@ -63,6 +55,14 @@ class Role extends Model implements Loggable
         return $this->hasMany(EntityPermission::class);
     }
 
+    /**
+     * Get all related entity collapsed permissions.
+     */
+    public function collapsedPermissions(): HasMany
+    {
+        return $this->hasMany(CollapsedPermission::class);
+    }
+
     /**
      * Check if this role has a permission.
      */
index 6e66bc808296c4c531568ac516c93412000a07c9..56a0890149cd2150150fd70ad3a6c1f72c62e89b 100644 (file)
@@ -5,6 +5,8 @@ namespace BookStack\Auth;
 use BookStack\Actions\Favourite;
 use BookStack\Api\ApiToken;
 use BookStack\Auth\Access\Mfa\MfaValue;
+use BookStack\Auth\Permissions\CollapsedPermission;
+use BookStack\Auth\Permissions\EntityPermission;
 use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Interfaces\Loggable;
 use BookStack\Interfaces\Sluggable;
@@ -298,6 +300,22 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
             }, 'activities', 'users.id', '=', 'activities.user_id');
     }
 
+    /**
+     * Get the entity permissions assigned to this specific user.
+     */
+    public function entityPermissions(): HasMany
+    {
+        return $this->hasMany(EntityPermission::class);
+    }
+
+    /**
+     * Get all related entity collapsed permissions.
+     */
+    public function collapsedPermissions(): HasMany
+    {
+        return $this->hasMany(CollapsedPermission::class);
+    }
+
     /**
      * Get the url for editing this user.
      */
index 78bcb978ebc997f3346d0e8dfb8a319cbfe641a4..1c9571b246caf11d879f8532bd3a1fc9a5907afc 100644 (file)
@@ -153,6 +153,8 @@ class UserRepo
         $user->apiTokens()->delete();
         $user->favourites()->delete();
         $user->mfaValues()->delete();
+        $user->collapsedPermissions()->delete();
+        $user->entityPermissions()->delete();
         $user->delete();
 
         // Delete user profile images
index efb3535d6484ba420fa41e34dc1cf6067f2fc6c5..2220b35a75046e1ff1ee264512571fca54ab558f 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Console\Commands;
 
-use BookStack\Auth\Permissions\JointPermissionBuilder;
+use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\DB;
 
@@ -22,12 +22,12 @@ class RegeneratePermissions extends Command
      */
     protected $description = 'Regenerate all system permissions';
 
-    protected JointPermissionBuilder $permissionBuilder;
+    protected CollapsedPermissionBuilder $permissionBuilder;
 
     /**
      * Create a new command instance.
      */
-    public function __construct(JointPermissionBuilder $permissionBuilder)
+    public function __construct(CollapsedPermissionBuilder $permissionBuilder)
     {
         $this->permissionBuilder = $permissionBuilder;
         parent::__construct();
index fc83bdd7eed6af9e371298087ee477d946618df2..2856d302fe327f4a28db1c92aba2d72f1ba55dc1 100644 (file)
@@ -7,10 +7,9 @@ use BookStack\Actions\Comment;
 use BookStack\Actions\Favourite;
 use BookStack\Actions\Tag;
 use BookStack\Actions\View;
+use BookStack\Auth\Permissions\CollapsedPermission;
 use BookStack\Auth\Permissions\EntityPermission;
-use BookStack\Auth\Permissions\JointPermission;
-use BookStack\Auth\Permissions\JointPermissionBuilder;
-use BookStack\Auth\Permissions\JointUserPermission;
+use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
 use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Interfaces\Deletable;
@@ -188,19 +187,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     }
 
     /**
-     * Get the entity jointPermissions this is connected to.
+     * Get the entity collapsed permissions this is connected to.
      */
-    public function jointPermissions(): MorphMany
+    public function collapsedPermissions(): MorphMany
     {
-        return $this->morphMany(JointPermission::class, 'entity');
-    }
-
-    /**
-     * Get the join user permissions for this entity.
-     */
-    public function jointUserPermissions(): MorphMany
-    {
-        return $this->morphMany(JointUserPermission::class, 'entity');
+        return $this->morphMany(CollapsedPermission::class, 'entity');
     }
 
     /**
@@ -301,7 +292,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
      */
     public function rebuildPermissions()
     {
-        app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
+        app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity(clone $this);
     }
 
     /**
index 7341a032816ac5a10b08b39277d1cf6cc3b2c0e7..c9804d9e2e9851f8110d9657855977b38131a42d 100644 (file)
@@ -372,7 +372,7 @@ class TrashCan
         $entity->permissions()->delete();
         $entity->tags()->delete();
         $entity->comments()->delete();
-        $entity->jointPermissions()->delete();
+        $entity->collapsedPermissions()->delete();
         $entity->searchTerms()->delete();
         $entity->deletions()->delete();
         $entity->favourites()->delete();
index 191eddf4d0596e33c5f5ba7af0a783620a2bda5d..eb4e07a665ef2a62fe0efd879fc8830dc0105ffa 100644 (file)
@@ -55,8 +55,9 @@ function hasAppAccess(): bool
 }
 
 /**
- * Check if the current user has a permission. If an ownable element
- * is passed in the jointPermissions are checked against that particular item.
+ * Check if the current user has a permission.
+ * Checks a generic role permission or, if an ownable model is passed in, it will
+ * check against the given entity model, taking into account entity-level permissions.
  */
 function userCan(string $permission, Model $ownable = null): bool
 {
index 748a8809dd92fe236bceb70b26e5fb3421464e71..c35fe973d40e7f086cad24fb9c0bc47e9215164a 100644 (file)
@@ -13,6 +13,9 @@ class CreateCollapsedRolePermissionsTable extends Migration
      */
     public function up()
     {
+        // TODO - Drop joint permissions
+        // TODO - Run collapsed table rebuild.
+
         Schema::create('entity_permissions_collapsed', function (Blueprint $table) {
             $table->id();
             $table->unsignedInteger('role_id')->nullable();
index f61e4e13df394e20adc51170110cf97feec37890..b670728b3f46bf4c342ab771ed878c1848ca7cd6 100644 (file)
@@ -3,7 +3,7 @@
 namespace Database\Seeders;
 
 use BookStack\Api\ApiToken;
-use BookStack\Auth\Permissions\JointPermissionBuilder;
+use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
@@ -69,7 +69,7 @@ class DummyContentSeeder extends Seeder
         ]);
         $token->save();
 
-        app(JointPermissionBuilder::class)->rebuildForAll();
+        app(CollapsedPermissionBuilder::class)->rebuildForAll();
         app(SearchIndex::class)->indexAllEntities();
     }
 }
index d0e65f9749024030436f1907671d0ab3a50df6f9..720a03ed55ce835b296a618af672591e23c61db5 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace Database\Seeders;
 
-use BookStack\Auth\Permissions\JointPermissionBuilder;
+use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
@@ -35,7 +35,7 @@ class LargeContentSeeder extends Seeder
         $largeBook->chapters()->saveMany($chapters);
         $all = array_merge([$largeBook], array_values($pages->all()), array_values($chapters->all()));
 
-        app()->make(JointPermissionBuilder::class)->rebuildForEntity($largeBook);
+        app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity($largeBook);
         app()->make(SearchIndex::class)->indexEntities($all);
     }
 }
index d514e5f9d90262c9ece2d2ad73ab425ab8f192bd..cc53b460d890a2f7826d29bdd86e2bf3b313717a 100644 (file)
@@ -2,8 +2,7 @@
 
 namespace Tests\Commands;
 
-use BookStack\Auth\Permissions\JointPermission;
-use BookStack\Entities\Models\Page;
+use BookStack\Auth\Permissions\CollapsedPermission;
 use Illuminate\Support\Facades\Artisan;
 use Illuminate\Support\Facades\DB;
 use Tests\TestCase;
@@ -13,15 +12,23 @@ class RegeneratePermissionsCommandTest extends TestCase
     public function test_regen_permissions_command()
     {
         DB::rollBack();
-        JointPermission::query()->truncate();
-        $page = Page::first();
+        $page = $this->entities->page();
+        $editor = $this->users->editor();
+        $this->permissions->addEntityPermission($page, ['view'], null, $editor);
+        CollapsedPermission::query()->truncate();
 
-        $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]);
+        $this->assertDatabaseMissing('entity_permissions_collapsed', ['entity_id' => $page->id]);
 
         $exitCode = Artisan::call('bookstack:regenerate-permissions');
         $this->assertTrue($exitCode === 0, 'Command executed successfully');
-        DB::beginTransaction();
 
-        $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]);
+        $this->assertDatabaseHas('entity_permissions_collapsed', [
+            'entity_id' => $page->id,
+            'user_id' => $editor->id,
+            'view' => 1,
+        ]);
+
+        CollapsedPermission::query()->truncate();
+        DB::beginTransaction();
     }
 }
index bebb5bada9af1c1fb815b7751c8810bef4d0fbe2..ac9a2a68a61f65a44d71a39c49a8eb76ea82f4f5 100644 (file)
@@ -3,7 +3,6 @@
 namespace Tests\Helpers;
 
 use BookStack\Auth\Permissions\EntityPermission;
-use BookStack\Auth\Permissions\JointPermissionBuilder;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
@@ -70,7 +69,6 @@ class PermissionsProvider
     public function regenerateForEntity(Entity $entity): void
     {
         $entity->rebuildPermissions();
-        $entity->load('jointPermissions');
     }
 
     /**
index 7df5bd42261ea7dd59fb1f4ca098d44adbfab9fa..afc7fcef36aafb4e2fd6fd0c92c8aee9c4102e69 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace Tests;
 
-use BookStack\Auth\Permissions\JointPermissionBuilder;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;