]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #3569 from BookStackApp/permissions_v2
authorDan Brown <redacted>
Sun, 17 Jul 2022 09:36:33 +0000 (10:36 +0100)
committerGitHub <redacted>
Sun, 17 Jul 2022 09:36:33 +0000 (10:36 +0100)
Permissions System Refactor

42 files changed:
app/Actions/ActivityLogger.php
app/Actions/ActivityQueries.php
app/Actions/TagRepo.php
app/Auth/Permissions/JointPermissionBuilder.php [new file with mode: 0644]
app/Auth/Permissions/PermissionApplicator.php [new file with mode: 0644]
app/Auth/Permissions/PermissionService.php [deleted file]
app/Auth/Permissions/PermissionsRepo.php
app/Auth/Permissions/SimpleEntityData.php [new file with mode: 0644]
app/Auth/User.php
app/Config/app.php
app/Console/Commands/RegeneratePermissions.php
app/Entities/Models/Bookshelf.php
app/Entities/Models/Entity.php
app/Entities/Models/Page.php
app/Entities/Queries/EntityQuery.php
app/Entities/Queries/Popular.php
app/Entities/Queries/RecentlyViewed.php
app/Entities/Queries/TopFavourites.php
app/Entities/Tools/SearchRunner.php
app/Entities/Tools/ShelfContext.php
app/Facades/Permissions.php [deleted file]
app/Http/Controllers/BookshelfController.php
app/Http/Controllers/SearchController.php
app/Providers/CustomFacadeProvider.php
app/Uploads/Attachment.php
app/Uploads/ImageRepo.php
app/helpers.php
database/migrations/2022_07_16_170051_drop_joint_permission_type.php [new file with mode: 0644]
database/seeders/DummyContentSeeder.php
database/seeders/LargeContentSeeder.php
resources/lang/en/entities.php
resources/sass/_lists.scss
resources/sass/_variables.scss
resources/views/chapters/show.blade.php
resources/views/entities/list-item.blade.php
resources/views/pages/show.blade.php
resources/views/search/parts/entity-ajax-list.blade.php
tests/Api/AttachmentsApiTest.php
tests/Commands/RegeneratePermissionsCommandTest.php
tests/Entity/EntitySearchTest.php
tests/PublicActionTest.php
tests/SharedTestHelpers.php

index 0d1391b43fe254b25a06dff72be446762950604d..eea5409fb95c07ddff1ccc16a678de233c58c656 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace BookStack\Actions;
 
-use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Entities\Models\Entity;
 use BookStack\Interfaces\Loggable;
 use Illuminate\Database\Eloquent\Builder;
@@ -10,13 +9,6 @@ use Illuminate\Support\Facades\Log;
 
 class ActivityLogger
 {
-    protected $permissionService;
-
-    public function __construct(PermissionService $permissionService)
-    {
-        $this->permissionService = $permissionService;
-    }
-
     /**
      * Add a generic activity event to the database.
      *
index f900fbb0544de725d8855528025384257610784e..0e9cbdebb4b003305bba1045dc59ac4888d764ad 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Actions;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
@@ -13,11 +13,11 @@ use Illuminate\Database\Eloquent\Relations\Relation;
 
 class ActivityQueries
 {
-    protected $permissionService;
+    protected PermissionApplicator $permissions;
 
-    public function __construct(PermissionService $permissionService)
+    public function __construct(PermissionApplicator $permissions)
     {
-        $this->permissionService = $permissionService;
+        $this->permissions = $permissions;
     }
 
     /**
@@ -25,8 +25,8 @@ class ActivityQueries
      */
     public function latest(int $count = 20, int $page = 0): array
     {
-        $activityList = $this->permissionService
-            ->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
+        $activityList = $this->permissions
+            ->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
             ->orderBy('created_at', 'desc')
             ->with(['user', 'entity'])
             ->skip($count * $page)
@@ -78,8 +78,8 @@ class ActivityQueries
      */
     public function userActivity(User $user, int $count = 20, int $page = 0): array
     {
-        $activityList = $this->permissionService
-            ->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
+        $activityList = $this->permissions
+            ->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
             ->orderBy('created_at', 'desc')
             ->where('user_id', '=', $user->id)
             ->skip($count * $page)
index 8cf1076019e6318785a3d5ed13fc8d4490b16517..172d8ec6ec0cfecb0b7059bfa693bbc91359d628 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Actions;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Entities\Models\Entity;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Collection;
@@ -10,12 +10,11 @@ use Illuminate\Support\Facades\DB;
 
 class TagRepo
 {
-    protected $tag;
-    protected $permissionService;
+    protected PermissionApplicator $permissions;
 
-    public function __construct(PermissionService $ps)
+    public function __construct(PermissionApplicator $permissions)
     {
-        $this->permissionService = $ps;
+        $this->permissions = $permissions;
     }
 
     /**
@@ -51,7 +50,7 @@ class TagRepo
             });
         }
 
-        return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
+        return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
     }
 
     /**
@@ -70,7 +69,7 @@ class TagRepo
             $query = $query->orderBy('count', 'desc')->take(50);
         }
 
-        $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
+        $query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
 
         return $query->get(['name'])->pluck('name');
     }
@@ -96,7 +95,7 @@ class TagRepo
             $query = $query->where('name', '=', $tagName);
         }
 
-        $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
+        $query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
 
         return $query->get(['value'])->pluck('value');
     }
diff --git a/app/Auth/Permissions/JointPermissionBuilder.php b/app/Auth/Permissions/JointPermissionBuilder.php
new file mode 100644 (file)
index 0000000..f377eef
--- /dev/null
@@ -0,0 +1,405 @@
+<?php
+
+namespace BookStack\Auth\Permissions;
+
+use BookStack\Auth\Role;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use Illuminate\Database\Eloquent\Builder;
+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.
+ */
+class JointPermissionBuilder
+{
+    /**
+     * @var array<string, array<int, SimpleEntityData>>
+     */
+    protected $entityCache;
+
+    /**
+     * Re-generate all entity permission from scratch.
+     */
+    public function rebuildForAll()
+    {
+        JointPermission::query()->truncate();
+
+        // Get all roles (Should be the most limited dimension)
+        $roles = Role::query()->with('permissions')->get()->all();
+
+        // Chunk through all books
+        $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
+            $this->buildJointPermissionsForBooks($books, $roles);
+        });
+
+        // Chunk through all bookshelves
+        Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
+            ->chunk(50, function (EloquentCollection $shelves) use ($roles) {
+                $this->createManyJointPermissions($shelves->all(), $roles);
+            });
+    }
+
+    /**
+     * Rebuild the entity jointPermissions 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, Role::query()->with('permissions')->get()->all(), true);
+
+            return;
+        }
+
+        /** @var BookChild $entity */
+        if ($entity->book) {
+            $entities[] = $entity->book;
+        }
+
+        if ($entity instanceof Page && $entity->chapter_id) {
+            $entities[] = $entity->chapter;
+        }
+
+        if ($entity instanceof Chapter) {
+            foreach ($entity->pages as $page) {
+                $entities[] = $page;
+            }
+        }
+
+        $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', 'restricted', '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.
+     */
+    protected function bookFetchQuery(): Builder
+    {
+        return Book::query()->withTrashed()
+            ->select(['id', 'restricted', 'owned_by'])->with([
+                'chapters' => function ($query) {
+                    $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
+                },
+                'pages' => function ($query) {
+                    $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
+                },
+            ]);
+    }
+
+    /**
+     * Build joint permissions for the given book and role combinations.
+     */
+    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
+    {
+        $entities = clone $books;
+
+        /** @var Book $book */
+        foreach ($books->all() as $book) {
+            foreach ($book->getRelation('chapters') as $chapter) {
+                $entities->push($chapter);
+            }
+            foreach ($book->getRelation('pages') as $page) {
+                $entities->push($page);
+            }
+        }
+
+        if ($deleteOld) {
+            $this->deleteManyJointPermissionsForEntities($entities->all());
+        }
+
+        $this->createManyJointPermissions($entities->all(), $roles);
+    }
+
+    /**
+     * Rebuild the entity jointPermissions for a collection of entities.
+     */
+    protected function buildJointPermissionsForEntities(array $entities)
+    {
+        $roles = Role::query()->get()->values()->all();
+        $this->deleteManyJointPermissionsForEntities($entities);
+        $this->createManyJointPermissions($entities, $roles);
+    }
+
+    /**
+     * Delete all the entity jointPermissions for a list of entities.
+     *
+     * @param Entity[] $entities
+     */
+    protected function deleteManyJointPermissionsForEntities(array $entities)
+    {
+        $simpleEntities = $this->entitiesToSimpleEntities($entities);
+        $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
+
+        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();
+                }
+            }
+        });
+    }
+
+    /**
+     * @param Entity[] $entities
+     *
+     * @return SimpleEntityData[]
+     */
+    protected function entitiesToSimpleEntities(array $entities): array
+    {
+        $simpleEntities = [];
+
+        foreach ($entities as $entity) {
+            $attrs = $entity->getAttributes();
+            $simple = new SimpleEntityData();
+            $simple->id = $attrs['id'];
+            $simple->type = $entity->getMorphClass();
+            $simple->restricted = boolval($attrs['restricted'] ?? 0);
+            $simple->owned_by = $attrs['owned_by'] ?? 0;
+            $simple->book_id = $attrs['book_id'] ?? null;
+            $simple->chapter_id = $attrs['chapter_id'] ?? null;
+            $simpleEntities[] = $simple;
+        }
+
+        return $simpleEntities;
+    }
+
+    /**
+     * Create & Save entity jointPermissions for many entities and roles.
+     *
+     * @param Entity[] $entities
+     * @param Role[]   $roles
+     */
+    protected function createManyJointPermissions(array $originalEntities, array $roles)
+    {
+        $entities = $this->entitiesToSimpleEntities($originalEntities);
+        $this->readyEntityCache($entities);
+        $jointPermissions = [];
+
+        // Create a mapping of entity restricted statuses
+        $entityRestrictedMap = [];
+        foreach ($entities as $entity) {
+            $entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
+        }
+
+        // Fetch related entity permissions
+        $permissions = $this->getEntityPermissionsForEntities($entities);
+
+        // Create a mapping of explicit entity permissions
+        $permissionMap = [];
+        foreach ($permissions as $permission) {
+            $key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id;
+            $isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
+            $permissionMap[$key] = $isRestricted;
+        }
+
+        // Create a mapping of role permissions
+        $rolePermissionMap = [];
+        foreach ($roles as $role) {
+            foreach ($role->permissions as $permission) {
+                $rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
+            }
+        }
+
+        // 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'
+                );
+            }
+        }
+
+        DB::transaction(function () use ($jointPermissions) {
+            foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
+                DB::table('joint_permissions')->insert($jointPermissionChunk);
+            }
+        });
+    }
+
+    /**
+     * From the given entity list, provide back a mapping of entity types to
+     * the ids of that given type. The type used is the DB morph class.
+     *
+     * @param SimpleEntityData[] $entities
+     *
+     * @return array<string, int[]>
+     */
+    protected function entitiesToTypeIdMap(array $entities): array
+    {
+        $idsByType = [];
+
+        foreach ($entities as $entity) {
+            if (!isset($idsByType[$entity->type])) {
+                $idsByType[$entity->type] = [];
+            }
+
+            $idsByType[$entity->type][] = $entity->id;
+        }
+
+        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('action', '=', 'view')
+            ->where(function (Builder $query) use ($idsByType) {
+                foreach ($idsByType as $type => $ids) {
+                    $query->orWhere(function (Builder $query) use ($type, $ids) {
+                        $query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
+                    });
+                }
+            });
+
+        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 ($entity->restricted) {
+            $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 = !$book->restricted;
+
+        // 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);
+            $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
+            if ($chapter->restricted) {
+                $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
+            }
+        }
+
+        return $this->createJointPermissionDataArray(
+            $entity,
+            $roleId,
+            ($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
+            ($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
+        );
+    }
+
+    /**
+     * Check for an active restriction in an entity map.
+     */
+    protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
+    {
+        $key = $entity->type . ':' . $entity->id . ':' . $roleId;
+
+        return $entityMap[$key] ?? 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,
+        ];
+    }
+}
diff --git a/app/Auth/Permissions/PermissionApplicator.php b/app/Auth/Permissions/PermissionApplicator.php
new file mode 100644 (file)
index 0000000..d855a61
--- /dev/null
@@ -0,0 +1,248 @@
+<?php
+
+namespace BookStack\Auth\Permissions;
+
+use BookStack\Auth\Role;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Model;
+use BookStack\Traits\HasCreatorAndUpdater;
+use BookStack\Traits\HasOwner;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
+use InvalidArgumentException;
+
+class PermissionApplicator
+{
+    /**
+     * Checks if an entity has a restriction set upon it.
+     *
+     * @param HasCreatorAndUpdater|HasOwner $ownable
+     */
+    public function checkOwnableUserAccess(Model $ownable, string $permission): bool
+    {
+        $explodedPermission = explode('-', $permission);
+        $action = $explodedPermission[1] ?? $explodedPermission[0];
+        $fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission;
+
+        $user = $this->currentUser();
+        $userRoleIds = $this->getCurrentUserRoleIds();
+
+        $allRolePermission = $user->can($fullPermission . '-all');
+        $ownRolePermission = $user->can($fullPermission . '-own');
+        $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
+        $ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
+        $isOwner = $user->id === $ownable->getAttribute($ownerField);
+        $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
+
+        // Handle non entity specific jointPermissions
+        if (in_array($explodedPermission[0], $nonJointPermissions)) {
+            return $hasRolePermission;
+        }
+
+        $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
+
+        return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
+    }
+
+    /**
+     * 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.
+     */
+    protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
+    {
+        $adminRoleId = Role::getSystemRole('admin')->id;
+        if (in_array($adminRoleId, $userRoleIds)) {
+            return true;
+        }
+
+        $chain = [$entity];
+        if ($entity instanceof Page && $entity->chapter_id) {
+            $chain[] = $entity->chapter;
+        }
+
+        if ($entity instanceof Page || $entity instanceof Chapter) {
+            $chain[] = $entity->book;
+        }
+
+        foreach ($chain as $currentEntity) {
+            if ($currentEntity->restricted) {
+                return $currentEntity->permissions()
+                    ->whereIn('role_id', $userRoleIds)
+                    ->where('action', '=', $action)
+                    ->count() > 0;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Checks if a user has the given permission for any items in the system.
+     * Can be passed an entity instance to filter on a specific type.
+     */
+    public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
+    {
+        if (strpos($action, '-') !== false) {
+            throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
+        }
+
+        $permissionQuery = EntityPermission::query()
+            ->where('action', '=', $action)
+            ->whereIn('role_id', $this->getCurrentUserRoleIds());
+
+        if (!empty($entityClass)) {
+            /** @var Entity $entityInstance */
+            $entityInstance = app()->make($entityClass);
+            $permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
+        }
+
+        $hasPermission = $permissionQuery->count() > 0;
+
+        return $hasPermission;
+    }
+
+    /**
+     * Limit the given entity query so that the query will only
+     * return items that the user has view permission for.
+     */
+    public function restrictEntityQuery(Builder $query): Builder
+    {
+        return $query->where(function (Builder $parentQuery) {
+            $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
+                $permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds())
+                    ->where(function (Builder $query) {
+                        $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
+                    });
+            });
+        });
+    }
+
+    /**
+     * Extend the given page query to ensure draft items are not visible
+     * unless created by the given user.
+     */
+    public function restrictDraftsOnPageQuery(Builder $query): Builder
+    {
+        return $query->where(function (Builder $query) {
+            $query->where('draft', '=', false)
+                ->orWhere(function (Builder $query) {
+                    $query->where('draft', '=', true)
+                        ->where('owned_by', '=', $this->currentUser()->id);
+                });
+        });
+    }
+
+    /**
+     * Filter items that have entities set as a polymorphic relation.
+     * For simplicity, this will not return results attached to draft pages.
+     * Draft pages should never really have related items though.
+     *
+     * @param Builder|QueryBuilder $query
+     */
+    public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
+    {
+        $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
+        $pageMorphClass = (new Page())->getMorphClass();
+
+        $q = $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
+            /** @var Builder $permissionQuery */
+            $permissionQuery->select(['role_id'])->from('joint_permissions')
+                ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                ->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
+                ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
+                ->where(function (QueryBuilder $query) {
+                    $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
+                });
+        })->where(function ($query) use ($tableDetails, $pageMorphClass) {
+            /** @var Builder $query */
+            $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
+                ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
+                    $query->select('id')->from('pages')
+                        ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                        ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
+                        ->where('pages.draft', '=', false);
+                });
+        });
+
+        return $q;
+    }
+
+    /**
+     * Add conditions to a query for a model that's a relation of a page, so only the model results
+     * on visible pages are returned by the query.
+     * Is effectively the same as "restrictEntityRelationQuery" but takes into account page drafts
+     * while not expecting a polymorphic relation, Just a simpler one-page-to-many-relations set-up.
+     */
+    public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
+    {
+        $fullPageIdColumn = $tableName . '.' . $pageIdColumn;
+        $morphClass = (new Page())->getMorphClass();
+
+        $existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) {
+            /** @var Builder $permissionQuery */
+            $permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
+                ->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn)
+                ->where('joint_permissions.entity_type', '=', $morphClass)
+                ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
+                ->where(function (QueryBuilder $query) {
+                    $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
+                });
+        };
+
+        $q = $query->where(function ($query) use ($existsQuery, $fullPageIdColumn) {
+            $query->whereExists($existsQuery)
+                ->orWhere($fullPageIdColumn, '=', 0);
+        });
+
+        // Prevent visibility of non-owned draft pages
+        $q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
+            $query->select('id')->from('pages')
+                ->whereColumn('pages.id', '=', $fullPageIdColumn)
+                ->where(function (QueryBuilder $query) {
+                    $query->where('pages.draft', '=', false)
+                        ->orWhere('pages.owned_by', '=', $this->currentUser()->id);
+                });
+        });
+
+        return $q;
+    }
+
+    /**
+     * Add the query for checking the given user id has permission
+     * within the join_permissions table.
+     *
+     * @param QueryBuilder|Builder $query
+     */
+    protected function addJointHasPermissionCheck($query, int $userIdToCheck)
+    {
+        $query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
+            $query->where('joint_permissions.has_permission_own', '=', true)
+                ->where('joint_permissions.owned_by', '=', $userIdToCheck);
+        });
+    }
+
+    /**
+     * Get the current user.
+     */
+    protected function currentUser(): User
+    {
+        return user();
+    }
+
+    /**
+     * Get the roles for the current logged-in user.
+     *
+     * @return int[]
+     */
+    protected function getCurrentUserRoleIds(): array
+    {
+        if (auth()->guest()) {
+            return [Role::getSystemRole('public')->id];
+        }
+
+        return $this->currentUser()->roles->pluck('id')->values()->all();
+    }
+}
diff --git a/app/Auth/Permissions/PermissionService.php b/app/Auth/Permissions/PermissionService.php
deleted file mode 100644 (file)
index 59ff37d..0000000
+++ /dev/null
@@ -1,719 +0,0 @@
-<?php
-
-namespace BookStack\Auth\Permissions;
-
-use BookStack\Auth\Role;
-use BookStack\Auth\User;
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\BookChild;
-use BookStack\Entities\Models\Bookshelf;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\Page;
-use BookStack\Model;
-use BookStack\Traits\HasCreatorAndUpdater;
-use BookStack\Traits\HasOwner;
-use Illuminate\Database\Connection;
-use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Database\Eloquent\Collection as EloquentCollection;
-use Illuminate\Database\Query\Builder as QueryBuilder;
-use Throwable;
-
-class PermissionService
-{
-    /**
-     * @var ?array
-     */
-    protected $userRoles = null;
-
-    /**
-     * @var ?User
-     */
-    protected $currentUserModel = null;
-
-    /**
-     * @var Connection
-     */
-    protected $db;
-
-    /**
-     * @var array
-     */
-    protected $entityCache;
-
-    /**
-     * PermissionService constructor.
-     */
-    public function __construct(Connection $db)
-    {
-        $this->db = $db;
-    }
-
-    /**
-     * Set the database connection.
-     */
-    public function setConnection(Connection $connection)
-    {
-        $this->db = $connection;
-    }
-
-    /**
-     * Prepare the local entity cache and ensure it's empty.
-     *
-     * @param Entity[] $entities
-     */
-    protected function readyEntityCache(array $entities = [])
-    {
-        $this->entityCache = [];
-
-        foreach ($entities as $entity) {
-            $class = get_class($entity);
-            if (!isset($this->entityCache[$class])) {
-                $this->entityCache[$class] = collect();
-            }
-            $this->entityCache[$class]->put($entity->id, $entity);
-        }
-    }
-
-    /**
-     * Get a book via ID, Checks local cache.
-     */
-    protected function getBook(int $bookId): ?Book
-    {
-        if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
-            return $this->entityCache[Book::class]->get($bookId);
-        }
-
-        return Book::query()->withTrashed()->find($bookId);
-    }
-
-    /**
-     * Get a chapter via ID, Checks local cache.
-     */
-    protected function getChapter(int $chapterId): ?Chapter
-    {
-        if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
-            return $this->entityCache[Chapter::class]->get($chapterId);
-        }
-
-        return Chapter::query()
-            ->withTrashed()
-            ->find($chapterId);
-    }
-
-    /**
-     * Get the roles for the current logged in user.
-     */
-    protected function getCurrentUserRoles(): array
-    {
-        if (!is_null($this->userRoles)) {
-            return $this->userRoles;
-        }
-
-        if (auth()->guest()) {
-            $this->userRoles = [Role::getSystemRole('public')->id];
-        } else {
-            $this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
-        }
-
-        return $this->userRoles;
-    }
-
-    /**
-     * Re-generate all entity permission from scratch.
-     */
-    public function buildJointPermissions()
-    {
-        JointPermission::query()->truncate();
-        $this->readyEntityCache();
-
-        // Get all roles (Should be the most limited dimension)
-        $roles = Role::query()->with('permissions')->get()->all();
-
-        // Chunk through all books
-        $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
-            $this->buildJointPermissionsForBooks($books, $roles);
-        });
-
-        // Chunk through all bookshelves
-        Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
-            ->chunk(50, function (EloquentCollection $shelves) use ($roles) {
-                $this->buildJointPermissionsForShelves($shelves, $roles);
-            });
-    }
-
-    /**
-     * Get a query for fetching a book with it's children.
-     */
-    protected function bookFetchQuery(): Builder
-    {
-        return Book::query()->withTrashed()
-            ->select(['id', 'restricted', 'owned_by'])->with([
-                'chapters' => function ($query) {
-                    $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
-                },
-                'pages' => function ($query) {
-                    $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
-                },
-            ]);
-    }
-
-    /**
-     * Build joint permissions for the given shelf and role combinations.
-     *
-     * @throws Throwable
-     */
-    protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
-    {
-        if ($deleteOld) {
-            $this->deleteManyJointPermissionsForEntities($shelves->all());
-        }
-        $this->createManyJointPermissions($shelves->all(), $roles);
-    }
-
-    /**
-     * Build joint permissions for the given book and role combinations.
-     *
-     * @throws Throwable
-     */
-    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
-    {
-        $entities = clone $books;
-
-        /** @var Book $book */
-        foreach ($books->all() as $book) {
-            foreach ($book->getRelation('chapters') as $chapter) {
-                $entities->push($chapter);
-            }
-            foreach ($book->getRelation('pages') as $page) {
-                $entities->push($page);
-            }
-        }
-
-        if ($deleteOld) {
-            $this->deleteManyJointPermissionsForEntities($entities->all());
-        }
-        $this->createManyJointPermissions($entities->all(), $roles);
-    }
-
-    /**
-     * Rebuild the entity jointPermissions for a particular entity.
-     *
-     * @throws Throwable
-     */
-    public function buildJointPermissionsForEntity(Entity $entity)
-    {
-        $entities = [$entity];
-        if ($entity instanceof Book) {
-            $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
-            $this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
-
-            return;
-        }
-
-        /** @var BookChild $entity */
-        if ($entity->book) {
-            $entities[] = $entity->book;
-        }
-
-        if ($entity instanceof Page && $entity->chapter_id) {
-            $entities[] = $entity->chapter;
-        }
-
-        if ($entity instanceof Chapter) {
-            foreach ($entity->pages as $page) {
-                $entities[] = $page;
-            }
-        }
-
-        $this->buildJointPermissionsForEntities($entities);
-    }
-
-    /**
-     * Rebuild the entity jointPermissions for a collection of entities.
-     *
-     * @throws Throwable
-     */
-    public function buildJointPermissionsForEntities(array $entities)
-    {
-        $roles = Role::query()->get()->values()->all();
-        $this->deleteManyJointPermissionsForEntities($entities);
-        $this->createManyJointPermissions($entities, $roles);
-    }
-
-    /**
-     * Build the entity jointPermissions for a particular role.
-     */
-    public function buildJointPermissionForRole(Role $role)
-    {
-        $roles = [$role];
-        $this->deleteManyJointPermissionsForRoles($roles);
-
-        // Chunk through all books
-        $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
-            $this->buildJointPermissionsForBooks($books, $roles);
-        });
-
-        // Chunk through all bookshelves
-        Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
-            ->chunk(50, function ($shelves) use ($roles) {
-                $this->buildJointPermissionsForShelves($shelves, $roles);
-            });
-    }
-
-    /**
-     * Delete the entity jointPermissions attached to a particular role.
-     */
-    public function deleteJointPermissionsForRole(Role $role)
-    {
-        $this->deleteManyJointPermissionsForRoles([$role]);
-    }
-
-    /**
-     * Delete all of the entity jointPermissions for a list of entities.
-     *
-     * @param Role[] $roles
-     */
-    protected function deleteManyJointPermissionsForRoles($roles)
-    {
-        $roleIds = array_map(function ($role) {
-            return $role->id;
-        }, $roles);
-        JointPermission::query()->whereIn('role_id', $roleIds)->delete();
-    }
-
-    /**
-     * Delete the entity jointPermissions for a particular entity.
-     *
-     * @param Entity $entity
-     *
-     * @throws Throwable
-     */
-    public function deleteJointPermissionsForEntity(Entity $entity)
-    {
-        $this->deleteManyJointPermissionsForEntities([$entity]);
-    }
-
-    /**
-     * Delete all of the entity jointPermissions for a list of entities.
-     *
-     * @param Entity[] $entities
-     *
-     * @throws Throwable
-     */
-    protected function deleteManyJointPermissionsForEntities(array $entities)
-    {
-        if (count($entities) === 0) {
-            return;
-        }
-
-        $this->db->transaction(function () use ($entities) {
-            foreach (array_chunk($entities, 1000) as $entityChunk) {
-                $query = $this->db->table('joint_permissions');
-                foreach ($entityChunk as $entity) {
-                    $query->orWhere(function (QueryBuilder $query) use ($entity) {
-                        $query->where('entity_id', '=', $entity->id)
-                            ->where('entity_type', '=', $entity->getMorphClass());
-                    });
-                }
-                $query->delete();
-            }
-        });
-    }
-
-    /**
-     * Create & Save entity jointPermissions for many entities and roles.
-     *
-     * @param Entity[] $entities
-     * @param Role[]   $roles
-     *
-     * @throws Throwable
-     */
-    protected function createManyJointPermissions(array $entities, array $roles)
-    {
-        $this->readyEntityCache($entities);
-        $jointPermissions = [];
-
-        // Fetch Entity Permissions and create a mapping of entity restricted statuses
-        $entityRestrictedMap = [];
-        $permissionFetch = EntityPermission::query();
-        foreach ($entities as $entity) {
-            $entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
-            $permissionFetch->orWhere(function ($query) use ($entity) {
-                $query->where('restrictable_id', '=', $entity->id)->where('restrictable_type', '=', $entity->getMorphClass());
-            });
-        }
-        $permissions = $permissionFetch->get();
-
-        // Create a mapping of explicit entity permissions
-        $permissionMap = [];
-        foreach ($permissions as $permission) {
-            $key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
-            $isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
-            $permissionMap[$key] = $isRestricted;
-        }
-
-        // Create a mapping of role permissions
-        $rolePermissionMap = [];
-        foreach ($roles as $role) {
-            foreach ($role->permissions as $permission) {
-                $rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
-            }
-        }
-
-        // Create Joint Permission Data
-        foreach ($entities as $entity) {
-            foreach ($roles as $role) {
-                foreach ($this->getActions($entity) as $action) {
-                    $jointPermissions[] = $this->createJointPermissionData($entity, $role, $action, $permissionMap, $rolePermissionMap);
-                }
-            }
-        }
-
-        $this->db->transaction(function () use ($jointPermissions) {
-            foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
-                $this->db->table('joint_permissions')->insert($jointPermissionChunk);
-            }
-        });
-    }
-
-    /**
-     * Get the actions related to an entity.
-     */
-    protected function getActions(Entity $entity): array
-    {
-        $baseActions = ['view', 'update', 'delete'];
-        if ($entity instanceof Chapter || $entity instanceof Book) {
-            $baseActions[] = 'page-create';
-        }
-        if ($entity instanceof Book) {
-            $baseActions[] = 'chapter-create';
-        }
-
-        return $baseActions;
-    }
-
-    /**
-     * Create entity permission data for an entity and role
-     * for a particular action.
-     */
-    protected function createJointPermissionData(Entity $entity, Role $role, string $action, array $permissionMap, array $rolePermissionMap): array
-    {
-        $permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
-        $roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
-        $roleHasPermissionOwn = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-own']);
-        $explodedAction = explode('-', $action);
-        $restrictionAction = end($explodedAction);
-
-        if ($role->system_name === 'admin') {
-            return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
-        }
-
-        if ($entity->restricted) {
-            $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
-
-            return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
-        }
-
-        if ($entity instanceof Book || $entity instanceof Bookshelf) {
-            return $this->createJointPermissionDataArray($entity, $role, $action, $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, $role, $restrictionAction);
-        $hasPermissiveAccessToParents = !$book->restricted;
-
-        // For pages with a chapter, Check if explicit permissions are set on the Chapter
-        if ($entity instanceof Page && intval($entity->chapter_id) !== 0) {
-            $chapter = $this->getChapter($entity->chapter_id);
-            $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
-            if ($chapter->restricted) {
-                $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $role, $restrictionAction);
-            }
-        }
-
-        return $this->createJointPermissionDataArray(
-            $entity,
-            $role,
-            $action,
-            ($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
-            ($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
-        );
-    }
-
-    /**
-     * Check for an active restriction in an entity map.
-     */
-    protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
-    {
-        $key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
-
-        return $entityMap[$key] ?? false;
-    }
-
-    /**
-     * Create an array of data with the information of an entity jointPermissions.
-     * Used to build data for bulk insertion.
-     */
-    protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
-    {
-        return [
-            'role_id'            => $role->getRawAttribute('id'),
-            'entity_id'          => $entity->getRawAttribute('id'),
-            'entity_type'        => $entity->getMorphClass(),
-            'action'             => $action,
-            'has_permission'     => $permissionAll,
-            'has_permission_own' => $permissionOwn,
-            'owned_by'           => $entity->getRawAttribute('owned_by'),
-        ];
-    }
-
-    /**
-     * Checks if an entity has a restriction set upon it.
-     *
-     * @param HasCreatorAndUpdater|HasOwner $ownable
-     */
-    public function checkOwnableUserAccess(Model $ownable, string $permission): bool
-    {
-        $explodedPermission = explode('-', $permission);
-
-        $baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
-        $action = end($explodedPermission);
-        $user = $this->currentUser();
-
-        $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
-
-        // Handle non entity specific jointPermissions
-        if (in_array($explodedPermission[0], $nonJointPermissions)) {
-            $allPermission = $user && $user->can($permission . '-all');
-            $ownPermission = $user && $user->can($permission . '-own');
-            $ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
-            $isOwner = $user && $user->id === $ownable->$ownerField;
-
-            return $allPermission || ($isOwner && $ownPermission);
-        }
-
-        // Handle abnormal create jointPermissions
-        if ($action === 'create') {
-            $action = $permission;
-        }
-
-        $hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
-        $this->clean();
-
-        return $hasAccess;
-    }
-
-    /**
-     * Checks if a user has the given permission for any items in the system.
-     * Can be passed an entity instance to filter on a specific type.
-     */
-    public function checkUserHasPermissionOnAnything(string $permission, ?string $entityClass = null): bool
-    {
-        $userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
-        $userId = $this->currentUser()->id;
-
-        $permissionQuery = JointPermission::query()
-            ->where('action', '=', $permission)
-            ->whereIn('role_id', $userRoleIds)
-            ->where(function (Builder $query) use ($userId) {
-                $this->addJointHasPermissionCheck($query, $userId);
-            });
-
-        if (!is_null($entityClass)) {
-            $entityInstance = app($entityClass);
-            $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
-        }
-
-        $hasPermission = $permissionQuery->count() > 0;
-        $this->clean();
-
-        return $hasPermission;
-    }
-
-    /**
-     * The general query filter to remove all entities
-     * that the current user does not have access to.
-     */
-    protected function entityRestrictionQuery(Builder $query, string $action): Builder
-    {
-        $q = $query->where(function ($parentQuery) use ($action) {
-            $parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
-                $permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
-                    ->where('action', '=', $action)
-                    ->where(function (Builder $query) {
-                        $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
-                    });
-            });
-        });
-
-        $this->clean();
-
-        return $q;
-    }
-
-    /**
-     * Limited the given entity query so that the query will only
-     * return items that the user has permission for the given ability.
-     */
-    public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
-    {
-        $this->clean();
-
-        return $query->where(function (Builder $parentQuery) use ($ability) {
-            $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
-                $permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
-                    ->where('action', '=', $ability)
-                    ->where(function (Builder $query) {
-                        $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
-                    });
-            });
-        });
-    }
-
-    /**
-     * Extend the given page query to ensure draft items are not visible
-     * unless created by the given user.
-     */
-    public function enforceDraftVisibilityOnQuery(Builder $query): Builder
-    {
-        return $query->where(function (Builder $query) {
-            $query->where('draft', '=', false)
-                ->orWhere(function (Builder $query) {
-                    $query->where('draft', '=', true)
-                        ->where('owned_by', '=', $this->currentUser()->id);
-                });
-        });
-    }
-
-    /**
-     * Add restrictions for a generic entity.
-     */
-    public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder
-    {
-        if ($entity instanceof Page) {
-            // Prevent drafts being visible to others.
-            $this->enforceDraftVisibilityOnQuery($query);
-        }
-
-        return $this->entityRestrictionQuery($query, $action);
-    }
-
-    /**
-     * Filter items that have entities set as a polymorphic relation.
-     * For simplicity, this will not return results attached to draft pages.
-     * Draft pages should never really have related items though.
-     *
-     * @param Builder|QueryBuilder $query
-     */
-    public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
-    {
-        $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
-        $pageMorphClass = (new Page())->getMorphClass();
-
-        $q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
-            /** @var Builder $permissionQuery */
-            $permissionQuery->select(['role_id'])->from('joint_permissions')
-                ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
-                ->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
-                ->where('joint_permissions.action', '=', $action)
-                ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
-                ->where(function (QueryBuilder $query) {
-                    $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
-                });
-        })->where(function ($query) use ($tableDetails, $pageMorphClass) {
-            /** @var Builder $query */
-            $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
-                ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
-                    $query->select('id')->from('pages')
-                        ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
-                        ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
-                        ->where('pages.draft', '=', false);
-                });
-        });
-
-        $this->clean();
-
-        return $q;
-    }
-
-    /**
-     * Add conditions to a query to filter the selection to related entities
-     * where view permissions are granted.
-     */
-    public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
-    {
-        $fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
-        $instance = new $entityClass();
-        $morphClass = $instance->getMorphClass();
-
-        $existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
-            /** @var Builder $permissionQuery */
-            $permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
-                ->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
-                ->where('joint_permissions.entity_type', '=', $morphClass)
-                ->where('joint_permissions.action', '=', 'view')
-                ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
-                ->where(function (QueryBuilder $query) {
-                    $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
-                });
-        };
-
-        $q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
-            $query->whereExists($existsQuery)
-                ->orWhere($fullEntityIdColumn, '=', 0);
-        });
-
-        if ($instance instanceof Page) {
-            // Prevent visibility of non-owned draft pages
-            $q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
-                $query->select('id')->from('pages')
-                    ->whereColumn('pages.id', '=', $fullEntityIdColumn)
-                    ->where(function (QueryBuilder $query) {
-                        $query->where('pages.draft', '=', false)
-                            ->orWhere('pages.owned_by', '=', $this->currentUser()->id);
-                    });
-            });
-        }
-
-        $this->clean();
-
-        return $q;
-    }
-
-    /**
-     * Add the query for checking the given user id has permission
-     * within the join_permissions table.
-     *
-     * @param QueryBuilder|Builder $query
-     */
-    protected function addJointHasPermissionCheck($query, int $userIdToCheck)
-    {
-        $query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
-            $query->where('joint_permissions.has_permission_own', '=', true)
-                ->where('joint_permissions.owned_by', '=', $userIdToCheck);
-        });
-    }
-
-    /**
-     * Get the current user.
-     */
-    private function currentUser(): User
-    {
-        if (is_null($this->currentUserModel)) {
-            $this->currentUserModel = user();
-        }
-
-        return $this->currentUserModel;
-    }
-
-    /**
-     * Clean the cached user elements.
-     */
-    private function clean(): void
-    {
-        $this->currentUserModel = null;
-        $this->userRoles = null;
-    }
-}
index 988146700f80e1760c6d667ac0fe29dc0de22542..2c2bedb725d0beea98fb03380fbc269af1123a62 100644 (file)
@@ -11,20 +11,15 @@ use Illuminate\Database\Eloquent\Collection;
 
 class PermissionsRepo
 {
-    protected $permission;
-    protected $role;
-    protected $permissionService;
-
+    protected JointPermissionBuilder $permissionBuilder;
     protected $systemRoles = ['admin', 'public'];
 
     /**
      * PermissionsRepo constructor.
      */
-    public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
+    public function __construct(JointPermissionBuilder $permissionBuilder)
     {
-        $this->permission = $permission;
-        $this->role = $role;
-        $this->permissionService = $permissionService;
+        $this->permissionBuilder = $permissionBuilder;
     }
 
     /**
@@ -32,7 +27,7 @@ class PermissionsRepo
      */
     public function getAllRoles(): Collection
     {
-        return $this->role->all();
+        return Role::query()->get();
     }
 
     /**
@@ -40,7 +35,7 @@ class PermissionsRepo
      */
     public function getAllRolesExcept(Role $role): Collection
     {
-        return $this->role->where('id', '!=', $role->id)->get();
+        return Role::query()->where('id', '!=', $role->id)->get();
     }
 
     /**
@@ -48,7 +43,7 @@ class PermissionsRepo
      */
     public function getRoleById($id): Role
     {
-        return $this->role->newQuery()->findOrFail($id);
+        return Role::query()->findOrFail($id);
     }
 
     /**
@@ -56,13 +51,14 @@ class PermissionsRepo
      */
     public function saveNewRole(array $roleData): Role
     {
-        $role = $this->role->newInstance($roleData);
+        $role = new Role($roleData);
         $role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
         $role->save();
 
         $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
         $this->assignRolePermissions($role, $permissions);
-        $this->permissionService->buildJointPermissionForRole($role);
+        $this->permissionBuilder->rebuildForRole($role);
+
         Activity::add(ActivityType::ROLE_CREATE, $role);
 
         return $role;
@@ -74,8 +70,7 @@ class PermissionsRepo
      */
     public function updateRole($roleId, array $roleData)
     {
-        /** @var Role $role */
-        $role = $this->role->newQuery()->findOrFail($roleId);
+        $role = $this->getRoleById($roleId);
 
         $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
         if ($role->system_name === 'admin') {
@@ -93,12 +88,13 @@ class PermissionsRepo
         $role->fill($roleData);
         $role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
         $role->save();
-        $this->permissionService->buildJointPermissionForRole($role);
+        $this->permissionBuilder->rebuildForRole($role);
+
         Activity::add(ActivityType::ROLE_UPDATE, $role);
     }
 
     /**
-     * Assign an list of permission names to an role.
+     * Assign a list of permission names to a role.
      */
     protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
     {
@@ -106,7 +102,7 @@ class PermissionsRepo
         $permissionNameArray = array_values($permissionNameArray);
 
         if ($permissionNameArray) {
-            $permissions = $this->permission->newQuery()
+            $permissions = RolePermission::query()
                 ->whereIn('name', $permissionNameArray)
                 ->pluck('id')
                 ->toArray();
@@ -126,8 +122,7 @@ class PermissionsRepo
      */
     public function deleteRole($roleId, $migrateRoleId)
     {
-        /** @var Role $role */
-        $role = $this->role->newQuery()->findOrFail($roleId);
+        $role = $this->getRoleById($roleId);
 
         // Prevent deleting admin role or default registration role.
         if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
@@ -137,14 +132,14 @@ class PermissionsRepo
         }
 
         if ($migrateRoleId) {
-            $newRole = $this->role->newQuery()->find($migrateRoleId);
+            $newRole = Role::query()->find($migrateRoleId);
             if ($newRole) {
                 $users = $role->users()->pluck('id')->toArray();
                 $newRole->users()->sync($users);
             }
         }
 
-        $this->permissionService->deleteJointPermissionsForRole($role);
+        $role->jointPermissions()->delete();
         Activity::add(ActivityType::ROLE_DELETE, $role);
         $role->delete();
     }
diff --git a/app/Auth/Permissions/SimpleEntityData.php b/app/Auth/Permissions/SimpleEntityData.php
new file mode 100644 (file)
index 0000000..6ec0c41
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+namespace BookStack\Auth\Permissions;
+
+class SimpleEntityData
+{
+    public int $id;
+    public string $type;
+    public bool $restricted;
+    public int $owned_by;
+    public ?int $book_id;
+    public ?int $chapter_id;
+}
index 4e21832449d5a6ab1068e82e3b136c151bf72f6f..c060d5ec8e65ad724e2eff8e8dee8e901e931d9f 100644 (file)
@@ -163,7 +163,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     }
 
     /**
-     * Get all permissions belonging to the current user.
+     * Get all permissions belonging to the current user.
      */
     protected function permissions(): Collection
     {
index 2b1de6f45e69aedb189da6343fd156981f50973c..5f516f4adfd5b766b7580bd3e03abff2d5c3ab30 100644 (file)
@@ -202,7 +202,6 @@ return [
 
         // Custom BookStack
         'Activity'    => BookStack\Facades\Activity::class,
-        'Permissions' => BookStack\Facades\Permissions::class,
         'Theme'       => BookStack\Facades\Theme::class,
     ],
 
index 4fde08e6b60e515004bdc0f5aea741a7fd44b91e..3396a445f0f531b4680302a6fae931dd5db1e0b0 100644 (file)
@@ -2,8 +2,9 @@
 
 namespace BookStack\Console\Commands;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\JointPermissionBuilder;
 use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
 
 class RegeneratePermissions extends Command
 {
@@ -21,19 +22,14 @@ class RegeneratePermissions extends Command
      */
     protected $description = 'Regenerate all system permissions';
 
-    /**
-     * The service to handle the permission system.
-     *
-     * @var PermissionService
-     */
-    protected $permissionService;
+    protected JointPermissionBuilder $permissionBuilder;
 
     /**
      * Create a new command instance.
      */
-    public function __construct(PermissionService $permissionService)
+    public function __construct(JointPermissionBuilder $permissionBuilder)
     {
-        $this->permissionService = $permissionService;
+        $this->permissionBuilder = $permissionBuilder;
         parent::__construct();
     }
 
@@ -44,15 +40,15 @@ class RegeneratePermissions extends Command
      */
     public function handle()
     {
-        $connection = \DB::getDefaultConnection();
-        if ($this->option('database') !== null) {
-            \DB::setDefaultConnection($this->option('database'));
-            $this->permissionService->setConnection(\DB::connection($this->option('database')));
+        $connection = DB::getDefaultConnection();
+
+        if ($this->option('database')) {
+            DB::setDefaultConnection($this->option('database'));
         }
 
-        $this->permissionService->buildJointPermissions();
+        $this->permissionBuilder->rebuildForAll();
 
-        \DB::setDefaultConnection($connection);
+        DB::setDefaultConnection($connection);
         $this->comment('Permissions regenerated');
     }
 }
index b9ebab92ef88da243a9ef069f35cce0e5ddd0e14..b2dab252a05decab60f407b0fe68541b92f9063e 100644 (file)
@@ -91,10 +91,6 @@ class Bookshelf extends Entity implements HasCoverImage
 
     /**
      * Check if this shelf contains the given book.
-     *
-     * @param Book $book
-     *
-     * @return bool
      */
     public function contains(Book $book): bool
     {
@@ -103,8 +99,6 @@ class Bookshelf extends Entity implements HasCoverImage
 
     /**
      * Add a book to the end of this shelf.
-     *
-     * @param Book $book
      */
     public function appendBook(Book $book)
     {
index 7ad78f1d1475d03c6131146335949bb29b29be82..17f018a566a01f76db1e39d64557b71cf2671c1e 100644 (file)
@@ -9,9 +9,10 @@ use BookStack\Actions\Tag;
 use BookStack\Actions\View;
 use BookStack\Auth\Permissions\EntityPermission;
 use BookStack\Auth\Permissions\JointPermission;
+use BookStack\Auth\Permissions\JointPermissionBuilder;
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Entities\Tools\SearchIndex;
 use BookStack\Entities\Tools\SlugGenerator;
-use BookStack\Facades\Permissions;
 use BookStack\Interfaces\Deletable;
 use BookStack\Interfaces\Favouritable;
 use BookStack\Interfaces\Loggable;
@@ -43,7 +44,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @property Collection $tags
  *
  * @method static Entity|Builder visible()
- * @method static Entity|Builder hasPermission(string $permission)
  * @method static Builder withLastView()
  * @method static Builder withViewCount()
  */
@@ -68,15 +68,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
      */
     public function scopeVisible(Builder $query): Builder
     {
-        return $this->scopeHasPermission($query, 'view');
-    }
-
-    /**
-     * Scope the query to those entities that the current user has the given permission for.
-     */
-    public function scopeHasPermission(Builder $query, string $permission)
-    {
-        return Permissions::restrictEntityQuery($query, $permission);
+        return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
     }
 
     /**
@@ -284,8 +276,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
      */
     public function rebuildPermissions()
     {
-        /** @noinspection PhpUnhandledExceptionInspection */
-        Permissions::buildJointPermissionsForEntity(clone $this);
+        app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
     }
 
     /**
@@ -293,7 +284,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
      */
     public function indexForSearch()
     {
-        app(SearchIndex::class)->indexEntity(clone $this);
+        app()->make(SearchIndex::class)->indexEntity(clone $this);
     }
 
     /**
@@ -301,7 +292,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
      */
     public function refreshSlug(): string
     {
-        $this->slug = app(SlugGenerator::class)->generate($this);
+        $this->slug = app()->make(SlugGenerator::class)->generate($this);
 
         return $this->slug;
     }
index ed69bcf8b8569d2e92fe94444d541872003a849b..93729d7f26177f679bf02eafcdc3b1d6b238716e 100644 (file)
@@ -2,8 +2,8 @@
 
 namespace BookStack\Entities\Models;
 
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Entities\Tools\PageContent;
-use BookStack\Facades\Permissions;
 use BookStack\Uploads\Attachment;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
@@ -51,7 +51,7 @@ class Page extends BookChild
      */
     public function scopeVisible(Builder $query): Builder
     {
-        $query = Permissions::enforceDraftVisibilityOnQuery($query);
+        $query = app()->make(PermissionApplicator::class)->restrictDraftsOnPageQuery($query);
 
         return parent::scopeVisible($query);
     }
index 76ab16ffc827f1373a186d41118fca54205e0239..5ab882ca89e957065a6e913ff8037db1139da277 100644 (file)
@@ -2,14 +2,14 @@
 
 namespace BookStack\Entities\Queries;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Entities\EntityProvider;
 
 abstract class EntityQuery
 {
-    protected function permissionService(): PermissionService
+    protected function permissionService(): PermissionApplicator
     {
-        return app()->make(PermissionService::class);
+        return app()->make(PermissionApplicator::class);
     }
 
     protected function entityProvider(): EntityProvider
index e6b22a1c95fb12880e163674b093c9adbd0ae3a3..fafd60c597b404aeb4da40e9c8648cfcdb99c004 100644 (file)
@@ -7,10 +7,10 @@ use Illuminate\Support\Facades\DB;
 
 class Popular extends EntityQuery
 {
-    public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
+    public function run(int $count, int $page, array $filterModels = null)
     {
         $query = $this->permissionService()
-            ->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action)
+            ->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
             ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
             ->groupBy('viewable_id', 'viewable_type')
             ->orderBy('view_count', 'desc');
index 5a29ecd7240c865c66738334e9c8f9dbff2fea7e..0f5c680414031dba6d4d2f1362bd3acd24c4f9e0 100644 (file)
@@ -14,12 +14,11 @@ class RecentlyViewed extends EntityQuery
             return collect();
         }
 
-        $query = $this->permissionService()->filterRestrictedEntityRelations(
+        $query = $this->permissionService()->restrictEntityRelationQuery(
             View::query(),
             'views',
             'viewable_id',
-            'viewable_type',
-            'view'
+            'viewable_type'
         )
             ->orderBy('views.updated_at', 'desc')
             ->where('user_id', '=', user()->id);
index 7522a894daa71b95bf6a85f806cbf52be6913a92..0b9a5ba23c6c1a488b5e0736f5f53bb2ca08ecc3 100644 (file)
@@ -15,7 +15,7 @@ class TopFavourites extends EntityQuery
         }
 
         $query = $this->permissionService()
-            ->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view')
+            ->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
             ->select('favourites.*')
             ->leftJoin('views', function (JoinClause $join) {
                 $join->on('favourites.favouritable_id', '=', 'views.viewable_id');
index a591914f3c1c85b20603ae9df2e5ba1634205a5d..78659b7864565d6d1f6edfeb0e843aecb0ba0678 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Entities\Tools;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Auth\User;
 use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\BookChild;
@@ -21,20 +21,13 @@ use SplObjectStorage;
 
 class SearchRunner
 {
-    /**
-     * @var EntityProvider
-     */
-    protected $entityProvider;
-
-    /**
-     * @var PermissionService
-     */
-    protected $permissionService;
+    protected EntityProvider $entityProvider;
+    protected PermissionApplicator $permissions;
 
     /**
      * Acceptable operators to be used in a query.
      *
-     * @var array
+     * @var string[]
      */
     protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
 
@@ -46,10 +39,10 @@ class SearchRunner
      */
     protected $termAdjustmentCache;
 
-    public function __construct(EntityProvider $entityProvider, PermissionService $permissionService)
+    public function __construct(EntityProvider $entityProvider, PermissionApplicator $permissions)
     {
         $this->entityProvider = $entityProvider;
-        $this->permissionService = $permissionService;
+        $this->permissions = $permissions;
         $this->termAdjustmentCache = new SplObjectStorage();
     }
 
@@ -60,7 +53,7 @@ class SearchRunner
      *
      * @return array{total: int, count: int, has_more: bool, results: Entity[]}
      */
-    public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
+    public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
     {
         $entityTypes = array_keys($this->entityProvider->all());
         $entityTypesToSearch = $entityTypes;
@@ -81,7 +74,7 @@ class SearchRunner
             }
 
             $entityModelInstance = $this->entityProvider->get($entityType);
-            $searchQuery = $this->buildQuery($searchOpts, $entityModelInstance, $action);
+            $searchQuery = $this->buildQuery($searchOpts, $entityModelInstance);
             $entityTotal = $searchQuery->count();
             $searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityModelInstance, $page, $count);
 
@@ -165,9 +158,9 @@ class SearchRunner
     /**
      * Create a search query for an entity.
      */
-    protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance, string $action = 'view'): EloquentBuilder
+    protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance): EloquentBuilder
     {
-        $entityQuery = $entityModelInstance->newQuery();
+        $entityQuery = $entityModelInstance->newQuery()->scopes('visible');
 
         if ($entityModelInstance instanceof Page) {
             $entityQuery->select($entityModelInstance::$listAttributes);
@@ -199,7 +192,7 @@ class SearchRunner
             }
         }
 
-        return $this->permissionService->enforceEntityRestrictions($entityModelInstance, $entityQuery, $action);
+        return $entityQuery;
     }
 
     /**
index 50d7981716e68c7dd662e982e22b115d64a0b4f2..50c7047d9e39607c2ba3459fc5e6e845a33dbc13 100644 (file)
@@ -20,6 +20,7 @@ class ShelfContext
             return null;
         }
 
+        /** @var Bookshelf $shelf */
         $shelf = Bookshelf::visible()->find($contextBookshelfId);
         $shelfContainsBook = $shelf && $shelf->contains($book);
 
diff --git a/app/Facades/Permissions.php b/app/Facades/Permissions.php
deleted file mode 100644 (file)
index 74cbe46..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-namespace BookStack\Facades;
-
-use Illuminate\Support\Facades\Facade;
-
-class Permissions extends Facade
-{
-    /**
-     * Get the registered name of the component.
-     *
-     * @return string
-     */
-    protected static function getFacadeAccessor()
-    {
-        return 'permissions';
-    }
-}
index a294bf7318c2a35a44d42ff3772b15f900a5123d..feb581c780579d470e5bf1437ac491f0591ebb9f 100644 (file)
@@ -68,7 +68,7 @@ class BookshelfController extends Controller
     public function create()
     {
         $this->checkPermission('bookshelf-create-all');
-        $books = Book::hasPermission('update')->get();
+        $books = Book::visible()->get();
         $this->setPageTitle(trans('entities.shelves_create'));
 
         return view('shelves.create', ['books' => $books]);
@@ -104,7 +104,7 @@ class BookshelfController extends Controller
     public function show(ActivityQueries $activities, string $slug)
     {
         $shelf = $this->bookshelfRepo->getBySlug($slug);
-        $this->checkOwnablePermission('book-view', $shelf);
+        $this->checkOwnablePermission('bookshelf-view', $shelf);
 
         $sort = setting()->getForCurrentUser('shelf_books_sort', 'default');
         $order = setting()->getForCurrentUser('shelf_books_sort_order', 'asc');
@@ -139,7 +139,7 @@ class BookshelfController extends Controller
         $this->checkOwnablePermission('bookshelf-update', $shelf);
 
         $shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
-        $books = Book::hasPermission('update')->whereNotIn('id', $shelfBookIds)->get();
+        $books = Book::visible()->whereNotIn('id', $shelfBookIds)->get();
 
         $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
 
index 6b2be5a2d77515433a2fbb4d78deedebb8bf1bda..4a002298cebb617de9eb0c34dd23cde56f920d44 100644 (file)
@@ -12,7 +12,6 @@ use Illuminate\Http\Request;
 class SearchController extends Controller
 {
     protected $searchRunner;
-    protected $entityContextManager;
 
     public function __construct(SearchRunner $searchRunner)
     {
@@ -79,12 +78,12 @@ class SearchController extends Controller
         // Search for entities otherwise show most popular
         if ($searchTerm !== false) {
             $searchTerm .= ' {type:' . implode('|', $entityTypes) . '}';
-            $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
+            $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20)['results'];
         } else {
-            $entities = (new Popular())->run(20, 0, $entityTypes, $permission);
+            $entities = (new Popular())->run(20, 0, $entityTypes);
         }
 
-        return view('search.parts.entity-ajax-list', ['entities' => $entities]);
+        return view('search.parts.entity-ajax-list', ['entities' => $entities, 'permission' => $permission]);
     }
 
     /**
index 0518af44f9bf6e4f55d4ae311a30313aa7b8f1db..6ba5632e6506766c5e8501b669720cf950bc29ae 100644 (file)
@@ -3,9 +3,7 @@
 namespace BookStack\Providers;
 
 use BookStack\Actions\ActivityLogger;
-use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Theming\ThemeService;
-use BookStack\Uploads\ImageService;
 use Illuminate\Support\ServiceProvider;
 
 class CustomFacadeProvider extends ServiceProvider
@@ -31,14 +29,6 @@ class CustomFacadeProvider extends ServiceProvider
             return $this->app->make(ActivityLogger::class);
         });
 
-        $this->app->singleton('images', function () {
-            return $this->app->make(ImageService::class);
-        });
-
-        $this->app->singleton('permissions', function () {
-            return $this->app->make(PermissionService::class);
-        });
-
         $this->app->singleton('theme', function () {
             return $this->app->make(ThemeService::class);
         });
index 5e637246a66b25b04f6256b92f7b8e1fca37778f..6c7066ff9701be00137b4d65c3a90f61d5ee038b 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Uploads;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
@@ -89,10 +89,9 @@ class Attachment extends Model
      */
     public function scopeVisible(): Builder
     {
-        $permissionService = app()->make(PermissionService::class);
+        $permissions = app()->make(PermissionApplicator::class);
 
-        return $permissionService->filterRelatedEntity(
-            Page::class,
+        return $permissions->restrictPageRelationQuery(
             self::query(),
             'attachments',
             'uploaded_to'
index bfe4b597739a8e26f423c4f5f04d9d78f0db805f..8770402adbdb0abe465a9fb866bedb932bf3cf32 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Uploads;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\ImageUploadException;
 use Exception;
@@ -11,16 +11,16 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class ImageRepo
 {
-    protected $imageService;
-    protected $restrictionService;
+    protected ImageService $imageService;
+    protected PermissionApplicator $permissions;
 
     /**
      * ImageRepo constructor.
      */
-    public function __construct(ImageService $imageService, PermissionService $permissionService)
+    public function __construct(ImageService $imageService, PermissionApplicator $permissions)
     {
         $this->imageService = $imageService;
-        $this->restrictionService = $permissionService;
+        $this->permissions = $permissions;
     }
 
     /**
@@ -74,7 +74,7 @@ class ImageRepo
         }
 
         // Filter by page access
-        $imageQuery = $this->restrictionService->filterRelatedEntity(Page::class, $imageQuery, 'images', 'uploaded_to');
+        $imageQuery = $this->permissions->restrictPageRelationQuery($imageQuery, 'images', 'uploaded_to');
 
         if ($whereClause !== null) {
             $imageQuery = $imageQuery->where($whereClause);
index 9edc22c403d9b1a7859baaec7983a8ad14e898a4..191eddf4d0596e33c5f5ba7af0a783620a2bda5d 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Auth\User;
 use BookStack\Model;
 use BookStack\Settings\SettingService;
@@ -65,20 +65,20 @@ function userCan(string $permission, Model $ownable = null): bool
     }
 
     // Check permission on ownable item
-    $permissionService = app(PermissionService::class);
+    $permissions = app(PermissionApplicator::class);
 
-    return $permissionService->checkOwnableUserAccess($ownable, $permission);
+    return $permissions->checkOwnableUserAccess($ownable, $permission);
 }
 
 /**
- * Check if the current user has the given permission
- * on any item in the system.
+ * Check if the current user can perform the given action on any items in the system.
+ * Can be provided the class name of an entity to filter ability to that specific entity type.
  */
-function userCanOnAny(string $permission, string $entityClass = null): bool
+function userCanOnAny(string $action, string $entityClass = ''): bool
 {
-    $permissionService = app(PermissionService::class);
+    $permissions = app(PermissionApplicator::class);
 
-    return $permissionService->checkUserHasPermissionOnAnything($permission, $entityClass);
+    return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass);
 }
 
 /**
diff --git a/database/migrations/2022_07_16_170051_drop_joint_permission_type.php b/database/migrations/2022_07_16_170051_drop_joint_permission_type.php
new file mode 100644 (file)
index 0000000..f34f736
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class DropJointPermissionType extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        DB::table('joint_permissions')
+            ->where('action', '!=', 'view')
+            ->delete();
+
+        Schema::table('joint_permissions', function (Blueprint $table) {
+            $table->dropPrimary(['role_id', 'entity_type', 'entity_id', 'action']);
+            $table->dropColumn('action');
+            $table->primary(['role_id', 'entity_type', 'entity_id'], 'joint_primary');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('joint_permissions', function (Blueprint $table) {
+            $table->string('action');
+            $table->dropPrimary(['role_id', 'entity_type', 'entity_id']);
+            $table->primary(['role_id', 'entity_type', 'entity_id', 'action']);
+        });
+    }
+}
index d54732b26357338c7d9b20bcd367254c1a0afe9c..e97069e7f5999afaee29e75817e9ebd7f476787a 100644 (file)
@@ -3,7 +3,7 @@
 namespace Database\Seeders;
 
 use BookStack\Api\ApiToken;
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\JointPermissionBuilder;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
@@ -69,7 +69,7 @@ class DummyContentSeeder extends Seeder
         ]);
         $token->save();
 
-        app(PermissionService::class)->buildJointPermissions();
+        app(JointPermissionBuilder::class)->rebuildForAll();
         app(SearchIndex::class)->indexAllEntities();
     }
 }
index dd916597876ba507fecd10c40739e37ff01a1672..b2d8a4d1b369eae0b19509cdb9cd1f2da624530e 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace Database\Seeders;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\JointPermissionBuilder;
 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(PermissionService::class)->buildJointPermissionsForEntity($largeBook);
+        app()->make(JointPermissionBuilder::class)->rebuildForEntity($largeBook);
         app()->make(SearchIndex::class)->indexEntities($all);
     }
 }
index 27d67487a0b0cb3aaffe6adb71c70d10f11c73e5..aa353bdacb63e162a5e2ef829ea029a998d88b40 100644 (file)
@@ -24,6 +24,7 @@ return [
     'meta_updated_name' => 'Updated :timeLength by :user',
     'meta_owned_name' => 'Owned by :user',
     'entity_select' => 'Entity Select',
+    'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item',
     'images' => 'Images',
     'my_recent_drafts' => 'My Recent Drafts',
     'my_recently_viewed' => 'My Recently Viewed',
index 19060fbbf5d3b02e0129656b40f6d5ccd94d46fe..5e251f9c7c1faa7d61c4a4054b394c0a3170b042 100644 (file)
@@ -443,6 +443,14 @@ ul.pagination {
   }
 }
 
+.entity-list-item.disabled {
+  pointer-events: none;
+  cursor: not-allowed;
+  opacity: 0.8;
+  user-select: none;
+  background: var(--bg-disabled);
+}
+
 .entity-list-item-path-sep {
   display: inline-block;
   vertical-align: top;
index 6b57147eff5ff16d387453b65ffcd4abb94ee2d5..3cb2dd4eddc5eda6ce1437fbd86ef96dc7c2ef5c 100644 (file)
@@ -45,6 +45,12 @@ $fs-s: 12px;
   --color-chapter: #af4d0d;
   --color-book: #077b70;
   --color-bookshelf: #a94747;
+
+  --bg-disabled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='19' height='19' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform='rotate(143)'%3E%3Crect width='100%25' height='100%25' fill='rgba(42, 67, 101,0)'/%3E%3Cpath d='M-10 30h60v20h-60zM-10-10h60v20h-60' fill='rgba(26, 32, 44,0)'/%3E%3Cpath d='M-10 10h60v20h-60zM-10-30h60v20h-60z' fill='rgba(0, 0, 0,0.05)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E");
+}
+
+:root.dark-mode {
+  --bg-disabled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='19' height='19' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform='rotate(143)'%3E%3Crect width='100%25' height='100%25' fill='rgba(42, 67, 101,0)'/%3E%3Cpath d='M-10 30h60v20h-60zM-10-10h60v20h-60' fill='rgba(26, 32, 44,0)'/%3E%3Cpath d='M-10 10h60v20h-60zM-10-30h60v20h-60z' fill='rgba(255, 255, 255,0.05)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E");
 }
 
 $positive: #0f7d15;
index 3e015616ad34384e1cf0f633543f4785e4d86844..8a86900fb1b744e81f1ed0b87a4aabf1e24f7a68 100644 (file)
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
-            @if(userCanOnAny('chapter-create'))
+            @if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCan('chapter-create-all') || userCan('chapter-create-own'))
                 <a href="{{ $chapter->getUrl('/copy') }}" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
index 44e06753d507a3df4ff17699b9fb0fda68558af4..2fadef1919360958f8acd014fd25116c62cac40d 100644 (file)
@@ -1,7 +1,13 @@
-@component('entities.list-item-basic', ['entity' => $entity])
+@component('entities.list-item-basic', ['entity' => $entity, 'classes' => (($locked ?? false) ? 'disabled ' : '') . ($classes ?? '') ])
 
 <div class="entity-item-snippet">
 
+    @if($locked ?? false)
+        <div class="text-warn my-xxs bold">
+            @icon('lock'){{ trans('entities.entity_select_lack_permission') }}
+        </div>
+    @endif
+
     @if($showPath ?? false)
         @if($entity->relationLoaded('book') && $entity->book)
             <span class="text-book">{{ $entity->book->getShortName(42) }}</span>
index 2a71c60214183698826027c2ecc0797cc1444e34..f1aed730b24b6bc9cb063fd272022db77a94108b 100644 (file)
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
-            @if(userCanOnAny('page-create'))
+            @if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCanOnAny('create', \BookStack\Entities\Models\Chapter::class) || userCan('page-create-all') || userCan('page-create-own'))
                 <a href="{{ $page->getUrl('/copy') }}" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
index a4eedf75e8c8e3d4a6804b324d84e6fe85cfda7d..9340ccdc5cce8531cf43e5f3c096d8c4eeb9d13d 100644 (file)
@@ -2,7 +2,12 @@
     @if(count($entities) > 0)
         @foreach($entities as $index => $entity)
 
-            @include('entities.list-item', ['entity' => $entity, 'showPath' => true])
+            @include('entities.list-item', [
+            'entity' => $entity,
+            'showPath' => true,
+            'locked' => $permission !== 'view' && !userCan($permission, $entity)
+            ])
+        
             @if($index !== count($entities) - 1)
                 <hr>
             @endif
index a343370161616503938bf566cb02ee60e0474553..6077868b26cc60b907991c8b588abea3ad6c8879 100644 (file)
@@ -262,7 +262,7 @@ class AttachmentsApiTest extends TestCase
         /** @var Page $page */
         $page = Page::query()->first();
         $page->draft = true;
-        $page->owned_by = $editor;
+        $page->owned_by = $editor->id;
         $page->save();
         $this->regenEntityPermissions($page);
 
index 2090c991bebbf4bcd4b363f4a31ff0363f5f38cc..d514e5f9d90262c9ece2d2ad73ab425ab8f192bd 100644 (file)
@@ -4,6 +4,7 @@ namespace Tests\Commands;
 
 use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\Artisan;
 use Illuminate\Support\Facades\DB;
 use Tests\TestCase;
 
@@ -11,13 +12,13 @@ class RegeneratePermissionsCommandTest extends TestCase
 {
     public function test_regen_permissions_command()
     {
-        \DB::rollBack();
+        DB::rollBack();
         JointPermission::query()->truncate();
         $page = Page::first();
 
         $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]);
 
-        $exitCode = \Artisan::call('bookstack:regenerate-permissions');
+        $exitCode = Artisan::call('bookstack:regenerate-permissions');
         $this->assertTrue($exitCode === 0, 'Command executed successfully');
         DB::beginTransaction();
 
index b535f5aaa7e177d08efb4717c5c761410aa1732d..a23a2fd26d8fe39dad85d87584230be1ff400348 100644 (file)
@@ -214,7 +214,7 @@ class EntitySearchTest extends TestCase
         $defaultListTest->assertDontSee($notVisitedPage->name);
     }
 
-    public function test_ajax_entity_serach_shows_breadcrumbs()
+    public function test_ajax_entity_search_shows_breadcrumbs()
     {
         $chapter = Chapter::first();
         $page = $chapter->pages->first();
@@ -230,6 +230,21 @@ class EntitySearchTest extends TestCase
         $chapterSearch->assertSee($chapter->book->getShortName(42));
     }
 
+    public function test_ajax_entity_search_reflects_items_without_permission()
+    {
+        $page = Page::query()->first();
+        $baseSelector = 'a[data-entity-type="page"][data-entity-id="' . $page->id . '"]';
+        $searchUrl = '/ajax/search/entities?permission=update&term=' . urlencode($page->name);
+
+        $resp = $this->asEditor()->get($searchUrl);
+        $resp->assertElementContains($baseSelector, $page->name);
+        $resp->assertElementNotContains($baseSelector, "You don't have the required permissions to select this item");
+
+        $resp = $this->actingAs($this->getViewer())->get($searchUrl);
+        $resp->assertElementContains($baseSelector, $page->name);
+        $resp->assertElementContains($baseSelector, "You don't have the required permissions to select this item");
+    }
+
     public function test_sibling_search_for_pages()
     {
         $chapter = Chapter::query()->with('pages')->first();
index 499c0c9f9710ab0bbd4c6f7401743c325dc2c6be..ced40d0a8e96e133617370ec00a4c00df04d015e 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace Tests;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\JointPermissionBuilder;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
@@ -89,7 +89,8 @@ class PublicActionTest extends TestCase
         foreach (RolePermission::all() as $perm) {
             $publicRole->attachPermission($perm);
         }
-        $this->app[PermissionService::class]->buildJointPermissionForRole($publicRole);
+        $this->app->make(JointPermissionBuilder::class)->rebuildForRole($publicRole);
+        user()->clearPermissionCache();
 
         /** @var Chapter $chapter */
         $chapter = Chapter::query()->first();
index ce57d56f51d0f473e73e6f63642997e11a7424fe..bc7b10266b06cfe9bc4fc0a78f2adefcfe920277 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace Tests;
 
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\JointPermissionBuilder;
 use BookStack\Auth\Permissions\PermissionsRepo;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
@@ -176,7 +176,7 @@ trait SharedTestHelpers
 
         $entity->save();
         $entity->load('permissions');
-        $this->app[PermissionService::class]->buildJointPermissionsForEntity($entity);
+        $this->app->make(JointPermissionBuilder::class)->rebuildForEntity($entity);
         $entity->load('jointPermissions');
     }
 
@@ -196,7 +196,7 @@ trait SharedTestHelpers
      */
     protected function removePermissionFromUser(User $user, string $permissionName)
     {
-        $permissionService = app()->make(PermissionService::class);
+        $permissionBuilder = app()->make(JointPermissionBuilder::class);
 
         /** @var RolePermission $permission */
         $permission = RolePermission::query()->where('name', '=', $permissionName)->firstOrFail();
@@ -208,7 +208,7 @@ trait SharedTestHelpers
         /** @var Role $role */
         foreach ($roles as $role) {
             $role->detachPermission($permission);
-            $permissionService->buildJointPermissionForRole($role);
+            $permissionBuilder->rebuildForRole($role);
         }
 
         $user->clearPermissionCache();
@@ -241,8 +241,8 @@ trait SharedTestHelpers
         $book = Book::factory()->create($userAttrs);
         $chapter = Chapter::factory()->create(array_merge(['book_id' => $book->id], $userAttrs));
         $page = Page::factory()->create(array_merge(['book_id' => $book->id, 'chapter_id' => $chapter->id], $userAttrs));
-        $restrictionService = $this->app[PermissionService::class];
-        $restrictionService->buildJointPermissionsForEntity($book);
+
+        $this->app->make(JointPermissionBuilder::class)->rebuildForEntity($book);
 
         return compact('book', 'chapter', 'page');
     }