diff --git a/app/Actions/ActivityLogger.php b/app/Actions/ActivityLogger.php index 0d1391b43..eea5409fb 100644 --- a/app/Actions/ActivityLogger.php +++ b/app/Actions/ActivityLogger.php @@ -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. * diff --git a/app/Actions/ActivityQueries.php b/app/Actions/ActivityQueries.php index f900fbb05..0e9cbdebb 100644 --- a/app/Actions/ActivityQueries.php +++ b/app/Actions/ActivityQueries.php @@ -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) diff --git a/app/Actions/TagRepo.php b/app/Actions/TagRepo.php index 8cf107601..172d8ec6e 100644 --- a/app/Actions/TagRepo.php +++ b/app/Actions/TagRepo.php @@ -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 index 000000000..f377eef5c --- /dev/null +++ b/app/Auth/Permissions/JointPermissionBuilder.php @@ -0,0 +1,405 @@ +> + */ + 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 + */ + 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 index 000000000..d855a6170 --- /dev/null +++ b/app/Auth/Permissions/PermissionApplicator.php @@ -0,0 +1,248 @@ + 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 index 59ff37dc9..000000000 --- a/app/Auth/Permissions/PermissionService.php +++ /dev/null @@ -1,719 +0,0 @@ -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; - } -} diff --git a/app/Auth/Permissions/PermissionsRepo.php b/app/Auth/Permissions/PermissionsRepo.php index 988146700..2c2bedb72 100644 --- a/app/Auth/Permissions/PermissionsRepo.php +++ b/app/Auth/Permissions/PermissionsRepo.php @@ -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 index 000000000..6ec0c4179 --- /dev/null +++ b/app/Auth/Permissions/SimpleEntityData.php @@ -0,0 +1,13 @@ + BookStack\Facades\Activity::class, - 'Permissions' => BookStack\Facades\Permissions::class, 'Theme' => BookStack\Facades\Theme::class, ], diff --git a/app/Console/Commands/RegeneratePermissions.php b/app/Console/Commands/RegeneratePermissions.php index 4fde08e6b..3396a445f 100644 --- a/app/Console/Commands/RegeneratePermissions.php +++ b/app/Console/Commands/RegeneratePermissions.php @@ -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'); } } diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index b9ebab92e..b2dab252a 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -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) { diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 7ad78f1d1..17f018a56 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -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; } diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index ed69bcf8b..93729d7f2 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -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); } diff --git a/app/Entities/Queries/EntityQuery.php b/app/Entities/Queries/EntityQuery.php index 76ab16ffc..5ab882ca8 100644 --- a/app/Entities/Queries/EntityQuery.php +++ b/app/Entities/Queries/EntityQuery.php @@ -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 diff --git a/app/Entities/Queries/Popular.php b/app/Entities/Queries/Popular.php index e6b22a1c9..fafd60c59 100644 --- a/app/Entities/Queries/Popular.php +++ b/app/Entities/Queries/Popular.php @@ -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'); diff --git a/app/Entities/Queries/RecentlyViewed.php b/app/Entities/Queries/RecentlyViewed.php index 5a29ecd72..0f5c68041 100644 --- a/app/Entities/Queries/RecentlyViewed.php +++ b/app/Entities/Queries/RecentlyViewed.php @@ -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); diff --git a/app/Entities/Queries/TopFavourites.php b/app/Entities/Queries/TopFavourites.php index 7522a894d..0b9a5ba23 100644 --- a/app/Entities/Queries/TopFavourites.php +++ b/app/Entities/Queries/TopFavourites.php @@ -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'); diff --git a/app/Entities/Tools/SearchRunner.php b/app/Entities/Tools/SearchRunner.php index a591914f3..78659b786 100644 --- a/app/Entities/Tools/SearchRunner.php +++ b/app/Entities/Tools/SearchRunner.php @@ -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; } /** diff --git a/app/Entities/Tools/ShelfContext.php b/app/Entities/Tools/ShelfContext.php index 50d798171..50c7047d9 100644 --- a/app/Entities/Tools/ShelfContext.php +++ b/app/Entities/Tools/ShelfContext.php @@ -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 index 74cbe46fe..000000000 --- a/app/Facades/Permissions.php +++ /dev/null @@ -1,18 +0,0 @@ -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()])); diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 6b2be5a2d..4a002298c 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -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]); } /** diff --git a/app/Providers/CustomFacadeProvider.php b/app/Providers/CustomFacadeProvider.php index 0518af44f..6ba5632e6 100644 --- a/app/Providers/CustomFacadeProvider.php +++ b/app/Providers/CustomFacadeProvider.php @@ -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); }); diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 5e637246a..6c7066ff9 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -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' diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index bfe4b5977..8770402ad 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -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); diff --git a/app/helpers.php b/app/helpers.php index 9edc22c40..191eddf4d 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1,6 +1,6 @@ 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 index 000000000..f34f73636 --- /dev/null +++ b/database/migrations/2022_07_16_170051_drop_joint_permission_type.php @@ -0,0 +1,41 @@ +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']); + }); + } +} diff --git a/database/seeders/DummyContentSeeder.php b/database/seeders/DummyContentSeeder.php index d54732b26..e97069e7f 100644 --- a/database/seeders/DummyContentSeeder.php +++ b/database/seeders/DummyContentSeeder.php @@ -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(); } } diff --git a/database/seeders/LargeContentSeeder.php b/database/seeders/LargeContentSeeder.php index dd9165978..b2d8a4d1b 100644 --- a/database/seeders/LargeContentSeeder.php +++ b/database/seeders/LargeContentSeeder.php @@ -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); } } diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 27d67487a..aa353bdac 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -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', diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 19060fbbf..5e251f9c7 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -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; diff --git a/resources/sass/_variables.scss b/resources/sass/_variables.scss index 6b57147ef..3cb2dd4ed 100644 --- a/resources/sass/_variables.scss +++ b/resources/sass/_variables.scss @@ -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; diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 3e015616a..8a86900fb 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -120,7 +120,7 @@ {{ trans('common.edit') }} @endif - @if(userCanOnAny('chapter-create')) + @if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCan('chapter-create-all') || userCan('chapter-create-own')) @icon('copy') {{ trans('common.copy') }} diff --git a/resources/views/entities/list-item.blade.php b/resources/views/entities/list-item.blade.php index 44e06753d..2fadef191 100644 --- a/resources/views/entities/list-item.blade.php +++ b/resources/views/entities/list-item.blade.php @@ -1,7 +1,13 @@ -@component('entities.list-item-basic', ['entity' => $entity]) +@component('entities.list-item-basic', ['entity' => $entity, 'classes' => (($locked ?? false) ? 'disabled ' : '') . ($classes ?? '') ])
+ @if($locked ?? false) +
+ @icon('lock'){{ trans('entities.entity_select_lack_permission') }} +
+ @endif + @if($showPath ?? false) @if($entity->relationLoaded('book') && $entity->book) {{ $entity->book->getShortName(42) }} diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index 2a71c6021..f1aed730b 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -148,7 +148,7 @@ {{ trans('common.edit') }}
@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')) @icon('copy') {{ trans('common.copy') }} diff --git a/resources/views/search/parts/entity-ajax-list.blade.php b/resources/views/search/parts/entity-ajax-list.blade.php index a4eedf75e..9340ccdc5 100644 --- a/resources/views/search/parts/entity-ajax-list.blade.php +++ b/resources/views/search/parts/entity-ajax-list.blade.php @@ -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)
@endif diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php index a34337016..6077868b2 100644 --- a/tests/Api/AttachmentsApiTest.php +++ b/tests/Api/AttachmentsApiTest.php @@ -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); diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php index 2090c991b..d514e5f9d 100644 --- a/tests/Commands/RegeneratePermissionsCommandTest.php +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -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(); diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index b535f5aaa..a23a2fd26 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -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(); diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index 499c0c9f9..ced40d0a8 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -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(); diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index ce57d56f5..bc7b10266 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -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'); }