1 <?php namespace BookStack\Auth\Permissions;
3 use BookStack\Auth\Permissions;
4 use BookStack\Auth\Role;
5 use BookStack\Entities\Book;
6 use BookStack\Entities\Entity;
7 use BookStack\Entities\EntityProvider;
9 use Illuminate\Database\Connection;
10 use Illuminate\Database\Eloquent\Builder;
11 use Illuminate\Database\Query\Builder as QueryBuilder;
12 use Illuminate\Support\Collection;
14 class PermissionService
17 protected $currentAction;
18 protected $isAdminUser;
19 protected $userRoles = false;
20 protected $currentUserModel = false;
28 * @var JointPermission
30 protected $jointPermission;
38 * @var EntityPermission
40 protected $entityPermission;
45 protected $entityProvider;
47 protected $entityCache;
50 * PermissionService constructor.
51 * @param JointPermission $jointPermission
52 * @param EntityPermission $entityPermission
54 * @param Connection $db
55 * @param EntityProvider $entityProvider
57 public function __construct(
58 JointPermission $jointPermission,
59 Permissions\EntityPermission $entityPermission,
62 EntityProvider $entityProvider
65 $this->jointPermission = $jointPermission;
66 $this->entityPermission = $entityPermission;
68 $this->entityProvider = $entityProvider;
72 * Set the database connection
73 * @param Connection $connection
75 public function setConnection(Connection $connection)
77 $this->db = $connection;
81 * Prepare the local entity cache and ensure it's empty
82 * @param \BookStack\Entities\Entity[] $entities
84 protected function readyEntityCache($entities = [])
86 $this->entityCache = [];
88 foreach ($entities as $entity) {
89 $type = $entity->getType();
90 if (!isset($this->entityCache[$type])) {
91 $this->entityCache[$type] = collect();
93 $this->entityCache[$type]->put($entity->id, $entity);
98 * Get a book via ID, Checks local cache
102 protected function getBook($bookId)
104 if (isset($this->entityCache['book']) && $this->entityCache['book']->has($bookId)) {
105 return $this->entityCache['book']->get($bookId);
108 $book = $this->entityProvider->book->find($bookId);
109 if ($book === null) {
117 * Get a chapter via ID, Checks local cache
119 * @return \BookStack\Entities\Book
121 protected function getChapter($chapterId)
123 if (isset($this->entityCache['chapter']) && $this->entityCache['chapter']->has($chapterId)) {
124 return $this->entityCache['chapter']->get($chapterId);
127 $chapter = $this->entityProvider->chapter->find($chapterId);
128 if ($chapter === null) {
136 * Get the roles for the current user;
139 protected function getRoles()
141 if ($this->userRoles !== false) {
142 return $this->userRoles;
147 if (auth()->guest()) {
148 $roles[] = $this->role->getSystemRole('public')->id;
153 foreach ($this->currentUser()->roles as $role) {
154 $roles[] = $role->id;
160 * Re-generate all entity permission from scratch.
162 public function buildJointPermissions()
164 $this->jointPermission->truncate();
165 $this->readyEntityCache();
167 // Get all roles (Should be the most limited dimension)
168 $roles = $this->role->with('permissions')->get()->all();
170 // Chunk through all books
171 $this->bookFetchQuery()->chunk(5, function ($books) use ($roles) {
172 $this->buildJointPermissionsForBooks($books, $roles);
175 // Chunk through all bookshelves
176 $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
177 ->chunk(50, function ($shelves) use ($roles) {
178 $this->buildJointPermissionsForShelves($shelves, $roles);
183 * Get a query for fetching a book with it's children.
184 * @return QueryBuilder
186 protected function bookFetchQuery()
188 return $this->entityProvider->book->newQuery()
189 ->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) {
190 $query->select(['id', 'restricted', 'created_by', 'book_id']);
191 }, 'pages' => function ($query) {
192 $query->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
197 * @param Collection $shelves
198 * @param array $roles
199 * @param bool $deleteOld
202 protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false)
205 $this->deleteManyJointPermissionsForEntities($shelves->all());
207 $this->createManyJointPermissions($shelves, $roles);
211 * Build joint permissions for an array of books
212 * @param Collection $books
213 * @param array $roles
214 * @param bool $deleteOld
216 protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
218 $entities = clone $books;
220 /** @var Book $book */
221 foreach ($books->all() as $book) {
222 foreach ($book->getRelation('chapters') as $chapter) {
223 $entities->push($chapter);
225 foreach ($book->getRelation('pages') as $page) {
226 $entities->push($page);
231 $this->deleteManyJointPermissionsForEntities($entities->all());
233 $this->createManyJointPermissions($entities, $roles);
237 * Rebuild the entity jointPermissions for a particular entity.
238 * @param \BookStack\Entities\Entity $entity
241 public function buildJointPermissionsForEntity(Entity $entity)
243 $entities = [$entity];
244 if ($entity->isA('book')) {
245 $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
246 $this->buildJointPermissionsForBooks($books, $this->role->newQuery()->get(), true);
251 $entities[] = $entity->book;
254 if ($entity->isA('page') && $entity->chapter_id) {
255 $entities[] = $entity->chapter;
258 if ($entity->isA('chapter')) {
259 foreach ($entity->pages as $page) {
264 $this->buildJointPermissionsForEntities(collect($entities));
268 * Rebuild the entity jointPermissions for a collection of entities.
269 * @param Collection $entities
272 public function buildJointPermissionsForEntities(Collection $entities)
274 $roles = $this->role->newQuery()->get();
275 $this->deleteManyJointPermissionsForEntities($entities->all());
276 $this->createManyJointPermissions($entities, $roles);
280 * Build the entity jointPermissions for a particular role.
283 public function buildJointPermissionForRole(Role $role)
286 $this->deleteManyJointPermissionsForRoles($roles);
288 // Chunk through all books
289 $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
290 $this->buildJointPermissionsForBooks($books, $roles);
293 // Chunk through all bookshelves
294 $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
295 ->chunk(50, function ($shelves) use ($roles) {
296 $this->buildJointPermissionsForShelves($shelves, $roles);
301 * Delete the entity jointPermissions attached to a particular role.
304 public function deleteJointPermissionsForRole(Role $role)
306 $this->deleteManyJointPermissionsForRoles([$role]);
310 * Delete all of the entity jointPermissions for a list of entities.
311 * @param Role[] $roles
313 protected function deleteManyJointPermissionsForRoles($roles)
315 $roleIds = array_map(function ($role) {
318 $this->jointPermission->newQuery()->whereIn('role_id', $roleIds)->delete();
322 * Delete the entity jointPermissions for a particular entity.
323 * @param Entity $entity
326 public function deleteJointPermissionsForEntity(Entity $entity)
328 $this->deleteManyJointPermissionsForEntities([$entity]);
332 * Delete all of the entity jointPermissions for a list of entities.
333 * @param \BookStack\Entities\Entity[] $entities
336 protected function deleteManyJointPermissionsForEntities($entities)
338 if (count($entities) === 0) {
342 $this->db->transaction(function () use ($entities) {
344 foreach (array_chunk($entities, 1000) as $entityChunk) {
345 $query = $this->db->table('joint_permissions');
346 foreach ($entityChunk as $entity) {
347 $query->orWhere(function (QueryBuilder $query) use ($entity) {
348 $query->where('entity_id', '=', $entity->id)
349 ->where('entity_type', '=', $entity->getMorphClass());
358 * Create & Save entity jointPermissions for many entities and jointPermissions.
359 * @param Collection $entities
360 * @param array $roles
363 protected function createManyJointPermissions($entities, $roles)
365 $this->readyEntityCache($entities);
366 $jointPermissions = [];
368 // Fetch Entity Permissions and create a mapping of entity restricted statuses
369 $entityRestrictedMap = [];
370 $permissionFetch = $this->entityPermission->newQuery();
371 foreach ($entities as $entity) {
372 $entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
373 $permissionFetch->orWhere(function ($query) use ($entity) {
374 $query->where('restrictable_id', '=', $entity->id)->where('restrictable_type', '=', $entity->getMorphClass());
377 $permissions = $permissionFetch->get();
379 // Create a mapping of explicit entity permissions
381 foreach ($permissions as $permission) {
382 $key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
383 $isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
384 $permissionMap[$key] = $isRestricted;
387 // Create a mapping of role permissions
388 $rolePermissionMap = [];
389 foreach ($roles as $role) {
390 foreach ($role->permissions as $permission) {
391 $rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
395 // Create Joint Permission Data
396 foreach ($entities as $entity) {
397 foreach ($roles as $role) {
398 foreach ($this->getActions($entity) as $action) {
399 $jointPermissions[] = $this->createJointPermissionData($entity, $role, $action, $permissionMap, $rolePermissionMap);
404 $this->db->transaction(function () use ($jointPermissions) {
405 foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
406 $this->db->table('joint_permissions')->insert($jointPermissionChunk);
413 * Get the actions related to an entity.
414 * @param \BookStack\Entities\Entity $entity
417 protected function getActions(Entity $entity)
419 $baseActions = ['view', 'update', 'delete'];
420 if ($entity->isA('chapter') || $entity->isA('book')) {
421 $baseActions[] = 'page-create';
423 if ($entity->isA('book')) {
424 $baseActions[] = 'chapter-create';
430 * Create entity permission data for an entity and role
431 * for a particular action.
432 * @param Entity $entity
434 * @param string $action
435 * @param array $permissionMap
436 * @param array $rolePermissionMap
439 protected function createJointPermissionData(Entity $entity, Role $role, $action, $permissionMap, $rolePermissionMap)
441 $permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
442 $roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
443 $roleHasPermissionOwn = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-own']);
444 $explodedAction = explode('-', $action);
445 $restrictionAction = end($explodedAction);
447 if ($role->system_name === 'admin') {
448 return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
451 if ($entity->restricted) {
452 $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
453 return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
456 if ($entity->isA('book') || $entity->isA('bookshelf')) {
457 return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
460 // For chapters and pages, Check if explicit permissions are set on the Book.
461 $book = $this->getBook($entity->book_id);
462 $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $role, $restrictionAction);
463 $hasPermissiveAccessToParents = !$book->restricted;
465 // For pages with a chapter, Check if explicit permissions are set on the Chapter
466 if ($entity->isA('page') && $entity->chapter_id !== 0 && $entity->chapter_id !== '0') {
467 $chapter = $this->getChapter($entity->chapter_id);
468 $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
469 if ($chapter->restricted) {
470 $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $role, $restrictionAction);
474 return $this->createJointPermissionDataArray(
478 ($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
479 ($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
484 * Check for an active restriction in an entity map.
486 * @param Entity $entity
491 protected function mapHasActiveRestriction($entityMap, Entity $entity, Role $role, $action)
493 $key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
494 return isset($entityMap[$key]) ? $entityMap[$key] : false;
498 * Create an array of data with the information of an entity jointPermissions.
499 * Used to build data for bulk insertion.
500 * @param \BookStack\Entities\Entity $entity
503 * @param $permissionAll
504 * @param $permissionOwn
507 protected function createJointPermissionDataArray(Entity $entity, Role $role, $action, $permissionAll, $permissionOwn)
510 'role_id' => $role->getRawAttribute('id'),
511 'entity_id' => $entity->getRawAttribute('id'),
512 'entity_type' => $entity->getMorphClass(),
514 'has_permission' => $permissionAll,
515 'has_permission_own' => $permissionOwn,
516 'created_by' => $entity->getRawAttribute('created_by')
521 * Checks if an entity has a restriction set upon it.
522 * @param Ownable $ownable
526 public function checkOwnableUserAccess(Ownable $ownable, $permission)
528 $explodedPermission = explode('-', $permission);
530 $baseQuery = $ownable->where('id', '=', $ownable->id);
531 $action = end($explodedPermission);
532 $this->currentAction = $action;
534 $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
536 // Handle non entity specific jointPermissions
537 if (in_array($explodedPermission[0], $nonJointPermissions)) {
538 $allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all');
539 $ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own');
540 $this->currentAction = 'view';
541 $isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->created_by;
542 return ($allPermission || ($isOwner && $ownPermission));
545 // Handle abnormal create jointPermissions
546 if ($action === 'create') {
547 $this->currentAction = $permission;
550 $q = $this->entityRestrictionQuery($baseQuery)->count() > 0;
556 * Checks if a user has the given permission for any items in the system.
557 * Can be passed an entity instance to filter on a specific type.
558 * @param string $permission
559 * @param string $entityClass
562 public function checkUserHasPermissionOnAnything(string $permission, string $entityClass = null)
564 $userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
565 $userId = $this->currentUser()->id;
567 $permissionQuery = $this->db->table('joint_permissions')
568 ->where('action', '=', $permission)
569 ->whereIn('role_id', $userRoleIds)
570 ->where(function ($query) use ($userId) {
571 $query->where('has_permission', '=', 1)
572 ->orWhere(function ($query2) use ($userId) {
573 $query2->where('has_permission_own', '=', 1)
574 ->where('created_by', '=', $userId);
578 if (!is_null($entityClass)) {
579 $entityInstance = app()->make($entityClass);
580 $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
583 $hasPermission = $permissionQuery->count() > 0;
585 return $hasPermission;
589 * Check if an entity has restrictions set on itself or its
591 * @param \BookStack\Entities\Entity $entity
595 public function checkIfRestrictionsSet(Entity $entity, $action)
597 $this->currentAction = $action;
598 if ($entity->isA('page')) {
599 return $entity->restricted || ($entity->chapter && $entity->chapter->restricted) || $entity->book->restricted;
600 } elseif ($entity->isA('chapter')) {
601 return $entity->restricted || $entity->book->restricted;
602 } elseif ($entity->isA('book')) {
603 return $entity->restricted;
608 * The general query filter to remove all entities
609 * that the current user does not have access to.
613 protected function entityRestrictionQuery($query)
615 $q = $query->where(function ($parentQuery) {
616 $parentQuery->whereHas('jointPermissions', function ($permissionQuery) {
617 $permissionQuery->whereIn('role_id', $this->getRoles())
618 ->where('action', '=', $this->currentAction)
619 ->where(function ($query) {
620 $query->where('has_permission', '=', true)
621 ->orWhere(function ($query) {
622 $query->where('has_permission_own', '=', true)
623 ->where('created_by', '=', $this->currentUser()->id);
633 * Limited the given entity query so that the query will only
634 * return items that the user has permission for the given ability.
636 public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
639 return $query->where(function (Builder $parentQuery) use ($ability) {
640 $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
641 $permissionQuery->whereIn('role_id', $this->getRoles())
642 ->where('action', '=', $ability)
643 ->where(function (Builder $query) {
644 $query->where('has_permission', '=', true)
645 ->orWhere(function (Builder $query) {
646 $query->where('has_permission_own', '=', true)
647 ->where('created_by', '=', $this->currentUser()->id);
655 * Extend the given page query to ensure draft items are not visible
656 * unless created by the given user.
658 public function enforceDraftVisiblityOnQuery(Builder $query): Builder
660 return $query->where(function (Builder $query) {
661 $query->where('draft', '=', false)
662 ->orWhere(function (Builder $query) {
663 $query->where('draft', '=', true)
664 ->where('created_by', '=', $this->currentUser()->id);
670 * Add restrictions for a generic entity
671 * @param string $entityType
672 * @param Builder|\BookStack\Entities\Entity $query
673 * @param string $action
676 public function enforceEntityRestrictions($entityType, $query, $action = 'view')
678 if (strtolower($entityType) === 'page') {
679 // Prevent drafts being visible to others.
680 $query = $query->where(function ($query) {
681 $query->where('draft', '=', false)
682 ->orWhere(function ($query) {
683 $query->where('draft', '=', true)
684 ->where('created_by', '=', $this->currentUser()->id);
689 $this->currentAction = $action;
690 return $this->entityRestrictionQuery($query);
694 * Filter items that have entities set as a polymorphic relation.
696 * @param string $tableName
697 * @param string $entityIdColumn
698 * @param string $entityTypeColumn
699 * @param string $action
700 * @return QueryBuilder
702 public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view')
705 $this->currentAction = $action;
706 $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
708 $q = $query->where(function ($query) use ($tableDetails) {
709 $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
710 $permissionQuery->select('id')->from('joint_permissions')
711 ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
712 ->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
713 ->where('action', '=', $this->currentAction)
714 ->whereIn('role_id', $this->getRoles())
715 ->where(function ($query) {
716 $query->where('has_permission', '=', true)->orWhere(function ($query) {
717 $query->where('has_permission_own', '=', true)
718 ->where('created_by', '=', $this->currentUser()->id);
728 * Add conditions to a query to filter the selection to related entities
729 * where permissions are granted.
733 * @param $entityIdColumn
736 public function filterRelatedEntity($entityType, $query, $tableName, $entityIdColumn)
738 $this->currentAction = 'view';
739 $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
741 $pageMorphClass = $this->entityProvider->get($entityType)->getMorphClass();
743 $q = $query->where(function ($query) use ($tableDetails, $pageMorphClass) {
744 $query->where(function ($query) use (&$tableDetails, $pageMorphClass) {
745 $query->whereExists(function ($permissionQuery) use (&$tableDetails, $pageMorphClass) {
746 $permissionQuery->select('id')->from('joint_permissions')
747 ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
748 ->where('entity_type', '=', $pageMorphClass)
749 ->where('action', '=', $this->currentAction)
750 ->whereIn('role_id', $this->getRoles())
751 ->where(function ($query) {
752 $query->where('has_permission', '=', true)->orWhere(function ($query) {
753 $query->where('has_permission_own', '=', true)
754 ->where('created_by', '=', $this->currentUser()->id);
758 })->orWhere($tableDetails['entityIdColumn'], '=', 0);
767 * Get the current user
768 * @return \BookStack\Auth\User
770 private function currentUser()
772 if ($this->currentUserModel === false) {
773 $this->currentUserModel = user();
776 return $this->currentUserModel;
780 * Clean the cached user elements.
782 private function clean()
784 $this->currentUserModel = false;
785 $this->userRoles = false;
786 $this->isAdminUser = null;