]> BookStack Code Mirror - bookstack/blob - app/Auth/Permissions/PermissionService.php
4565986535d6240a9a0c80211bc61f0929117aa6
[bookstack] / app / Auth / Permissions / PermissionService.php
1 <?php namespace BookStack\Auth\Permissions;
2
3 use BookStack\Auth\Role;
4 use BookStack\Auth\User;
5 use BookStack\Entities\Models\Book;
6 use BookStack\Entities\Models\BookChild;
7 use BookStack\Entities\Models\Bookshelf;
8 use BookStack\Entities\Models\Chapter;
9 use BookStack\Entities\Models\Entity;
10 use BookStack\Entities\Models\Page;
11 use BookStack\Model;
12 use BookStack\Traits\HasCreatorAndUpdater;
13 use BookStack\Traits\HasOwner;
14 use Illuminate\Database\Connection;
15 use Illuminate\Database\Eloquent\Builder;
16 use Illuminate\Database\Eloquent\Collection as EloquentCollection;
17 use Illuminate\Database\Query\Builder as QueryBuilder;
18 use Throwable;
19
20 class PermissionService
21 {
22     /**
23      * @var ?array
24      */
25     protected $userRoles = null;
26
27     /**
28      * @var ?User
29      */
30     protected $currentUserModel = null;
31
32     /**
33      * @var Connection
34      */
35     protected $db;
36
37     /**
38      * @var array
39      */
40     protected $entityCache;
41
42     /**
43      * PermissionService constructor.
44      */
45     public function __construct(Connection $db)
46     {
47         $this->db = $db;
48     }
49
50     /**
51      * Set the database connection
52      */
53     public function setConnection(Connection $connection)
54     {
55         $this->db = $connection;
56     }
57
58     /**
59      * Prepare the local entity cache and ensure it's empty
60      * @param Entity[] $entities
61      */
62     protected function readyEntityCache(array $entities = [])
63     {
64         $this->entityCache = [];
65
66         foreach ($entities as $entity) {
67             $class = get_class($entity);
68             if (!isset($this->entityCache[$class])) {
69                 $this->entityCache[$class] = collect();
70             }
71             $this->entityCache[$class]->put($entity->id, $entity);
72         }
73     }
74
75     /**
76      * Get a book via ID, Checks local cache
77      */
78     protected function getBook(int $bookId): ?Book
79     {
80         if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
81             return $this->entityCache[Book::class]->get($bookId);
82         }
83
84         return Book::query()->withTrashed()->find($bookId);
85     }
86
87     /**
88      * Get a chapter via ID, Checks local cache
89      */
90     protected function getChapter(int $chapterId): ?Chapter
91     {
92         if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
93             return $this->entityCache[Chapter::class]->get($chapterId);
94         }
95
96         return Chapter::query()
97             ->withTrashed()
98             ->find($chapterId);
99     }
100
101     /**
102      * Get the roles for the current logged in user.
103      */
104     protected function getCurrentUserRoles(): array
105     {
106         if (!is_null($this->userRoles)) {
107             return $this->userRoles;
108         }
109
110         if (auth()->guest()) {
111             $this->userRoles = [Role::getSystemRole('public')->id];
112         } else {
113             $this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
114         }
115
116         return $this->userRoles;
117     }
118
119     /**
120      * Re-generate all entity permission from scratch.
121      */
122     public function buildJointPermissions()
123     {
124         JointPermission::query()->truncate();
125         $this->readyEntityCache();
126
127         // Get all roles (Should be the most limited dimension)
128         $roles = Role::query()->with('permissions')->get()->all();
129
130         // Chunk through all books
131         $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
132             $this->buildJointPermissionsForBooks($books, $roles);
133         });
134
135         // Chunk through all bookshelves
136         Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
137             ->chunk(50, function (EloquentCollection $shelves) use ($roles) {
138                 $this->buildJointPermissionsForShelves($shelves, $roles);
139             });
140     }
141
142     /**
143      * Get a query for fetching a book with it's children.
144      */
145     protected function bookFetchQuery(): Builder
146     {
147         return Book::query()->withTrashed()
148             ->select(['id', 'restricted', 'owned_by'])->with([
149                 'chapters' => function ($query) {
150                     $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
151                 },
152                 'pages' => function ($query) {
153                     $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
154                 }
155             ]);
156     }
157
158     /**
159      * Build joint permissions for the given shelf and role combinations.
160      * @throws Throwable
161      */
162     protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
163     {
164         if ($deleteOld) {
165             $this->deleteManyJointPermissionsForEntities($shelves->all());
166         }
167         $this->createManyJointPermissions($shelves->all(), $roles);
168     }
169
170     /**
171      * Build joint permissions for the given book and role combinations.
172      * @throws Throwable
173      */
174     protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
175     {
176         $entities = clone $books;
177
178         /** @var Book $book */
179         foreach ($books->all() as $book) {
180             foreach ($book->getRelation('chapters') as $chapter) {
181                 $entities->push($chapter);
182             }
183             foreach ($book->getRelation('pages') as $page) {
184                 $entities->push($page);
185             }
186         }
187
188         if ($deleteOld) {
189             $this->deleteManyJointPermissionsForEntities($entities->all());
190         }
191         $this->createManyJointPermissions($entities->all(), $roles);
192     }
193
194     /**
195      * Rebuild the entity jointPermissions for a particular entity.
196      * @throws Throwable
197      */
198     public function buildJointPermissionsForEntity(Entity $entity)
199     {
200         $entities = [$entity];
201         if ($entity instanceof Book) {
202             $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
203             $this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
204             return;
205         }
206
207         /** @var BookChild $entity */
208         if ($entity->book) {
209             $entities[] = $entity->book;
210         }
211
212         if ($entity instanceof Page && $entity->chapter_id) {
213             $entities[] = $entity->chapter;
214         }
215
216         if ($entity instanceof Chapter) {
217             foreach ($entity->pages as $page) {
218                 $entities[] = $page;
219             }
220         }
221
222         $this->buildJointPermissionsForEntities($entities);
223     }
224
225     /**
226      * Rebuild the entity jointPermissions for a collection of entities.
227      * @throws Throwable
228      */
229     public function buildJointPermissionsForEntities(array $entities)
230     {
231         $roles = Role::query()->get()->values()->all();
232         $this->deleteManyJointPermissionsForEntities($entities);
233         $this->createManyJointPermissions($entities, $roles);
234     }
235
236     /**
237      * Build the entity jointPermissions for a particular role.
238      */
239     public function buildJointPermissionForRole(Role $role)
240     {
241         $roles = [$role];
242         $this->deleteManyJointPermissionsForRoles($roles);
243
244         // Chunk through all books
245         $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
246             $this->buildJointPermissionsForBooks($books, $roles);
247         });
248
249         // Chunk through all bookshelves
250         Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
251             ->chunk(50, function ($shelves) use ($roles) {
252                 $this->buildJointPermissionsForShelves($shelves, $roles);
253             });
254     }
255
256     /**
257      * Delete the entity jointPermissions attached to a particular role.
258      */
259     public function deleteJointPermissionsForRole(Role $role)
260     {
261         $this->deleteManyJointPermissionsForRoles([$role]);
262     }
263
264     /**
265      * Delete all of the entity jointPermissions for a list of entities.
266      * @param Role[] $roles
267      */
268     protected function deleteManyJointPermissionsForRoles($roles)
269     {
270         $roleIds = array_map(function ($role) {
271             return $role->id;
272         }, $roles);
273         JointPermission::query()->whereIn('role_id', $roleIds)->delete();
274     }
275
276     /**
277      * Delete the entity jointPermissions for a particular entity.
278      * @param Entity $entity
279      * @throws Throwable
280      */
281     public function deleteJointPermissionsForEntity(Entity $entity)
282     {
283         $this->deleteManyJointPermissionsForEntities([$entity]);
284     }
285
286     /**
287      * Delete all of the entity jointPermissions for a list of entities.
288      * @param Entity[] $entities
289      * @throws Throwable
290      */
291     protected function deleteManyJointPermissionsForEntities(array $entities)
292     {
293         if (count($entities) === 0) {
294             return;
295         }
296
297         $this->db->transaction(function () use ($entities) {
298
299             foreach (array_chunk($entities, 1000) as $entityChunk) {
300                 $query = $this->db->table('joint_permissions');
301                 foreach ($entityChunk as $entity) {
302                     $query->orWhere(function (QueryBuilder $query) use ($entity) {
303                         $query->where('entity_id', '=', $entity->id)
304                             ->where('entity_type', '=', $entity->getMorphClass());
305                     });
306                 }
307                 $query->delete();
308             }
309         });
310     }
311
312     /**
313      * Create & Save entity jointPermissions for many entities and roles.
314      * @param Entity[] $entities
315      * @param Role[] $roles
316      * @throws Throwable
317      */
318     protected function createManyJointPermissions(array $entities, array $roles)
319     {
320         $this->readyEntityCache($entities);
321         $jointPermissions = [];
322
323         // Fetch Entity Permissions and create a mapping of entity restricted statuses
324         $entityRestrictedMap = [];
325         $permissionFetch = EntityPermission::query();
326         foreach ($entities as $entity) {
327             $entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
328             $permissionFetch->orWhere(function ($query) use ($entity) {
329                 $query->where('restrictable_id', '=', $entity->id)->where('restrictable_type', '=', $entity->getMorphClass());
330             });
331         }
332         $permissions = $permissionFetch->get();
333
334         // Create a mapping of explicit entity permissions
335         $permissionMap = [];
336         foreach ($permissions as $permission) {
337             $key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
338             $isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
339             $permissionMap[$key] = $isRestricted;
340         }
341
342         // Create a mapping of role permissions
343         $rolePermissionMap = [];
344         foreach ($roles as $role) {
345             foreach ($role->permissions as $permission) {
346                 $rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
347             }
348         }
349
350         // Create Joint Permission Data
351         foreach ($entities as $entity) {
352             foreach ($roles as $role) {
353                 foreach ($this->getActions($entity) as $action) {
354                     $jointPermissions[] = $this->createJointPermissionData($entity, $role, $action, $permissionMap, $rolePermissionMap);
355                 }
356             }
357         }
358
359         $this->db->transaction(function () use ($jointPermissions) {
360             foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
361                 $this->db->table('joint_permissions')->insert($jointPermissionChunk);
362             }
363         });
364     }
365
366
367     /**
368      * Get the actions related to an entity.
369      */
370     protected function getActions(Entity $entity): array
371     {
372         $baseActions = ['view', 'update', 'delete'];
373         if ($entity instanceof Chapter || $entity instanceof Book) {
374             $baseActions[] = 'page-create';
375         }
376         if ($entity instanceof Book) {
377             $baseActions[] = 'chapter-create';
378         }
379         return $baseActions;
380     }
381
382     /**
383      * Create entity permission data for an entity and role
384      * for a particular action.
385      */
386     protected function createJointPermissionData(Entity $entity, Role $role, string $action, array $permissionMap, array $rolePermissionMap): array
387     {
388         $permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
389         $roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
390         $roleHasPermissionOwn = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-own']);
391         $explodedAction = explode('-', $action);
392         $restrictionAction = end($explodedAction);
393
394         if ($role->system_name === 'admin') {
395             return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
396         }
397
398         if ($entity->restricted) {
399             $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
400             return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
401         }
402
403         if ($entity instanceof Book || $entity instanceof Bookshelf) {
404             return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
405         }
406
407         // For chapters and pages, Check if explicit permissions are set on the Book.
408         $book = $this->getBook($entity->book_id);
409         $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $role, $restrictionAction);
410         $hasPermissiveAccessToParents = !$book->restricted;
411
412         // For pages with a chapter, Check if explicit permissions are set on the Chapter
413         if ($entity instanceof Page && intval($entity->chapter_id) !== 0) {
414             $chapter = $this->getChapter($entity->chapter_id);
415             $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
416             if ($chapter->restricted) {
417                 $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $role, $restrictionAction);
418             }
419         }
420
421         return $this->createJointPermissionDataArray(
422             $entity,
423             $role,
424             $action,
425             ($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
426             ($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
427         );
428     }
429
430     /**
431      * Check for an active restriction in an entity map.
432      */
433     protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
434     {
435         $key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
436         return $entityMap[$key] ?? false;
437     }
438
439     /**
440      * Create an array of data with the information of an entity jointPermissions.
441      * Used to build data for bulk insertion.
442      */
443     protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
444     {
445         return [
446             'role_id' => $role->getRawAttribute('id'),
447             'entity_id' => $entity->getRawAttribute('id'),
448             'entity_type' => $entity->getMorphClass(),
449             'action' => $action,
450             'has_permission' => $permissionAll,
451             'has_permission_own' => $permissionOwn,
452             'owned_by' => $entity->getRawAttribute('owned_by'),
453         ];
454     }
455
456     /**
457      * Checks if an entity has a restriction set upon it.
458      * @param HasCreatorAndUpdater|HasOwner $ownable
459      */
460     public function checkOwnableUserAccess(Model $ownable, string $permission): bool
461     {
462         $explodedPermission = explode('-', $permission);
463
464         $baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
465         $action = end($explodedPermission);
466         $user = $this->currentUser();
467
468         $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
469
470         // Handle non entity specific jointPermissions
471         if (in_array($explodedPermission[0], $nonJointPermissions)) {
472             $allPermission = $user && $user->can($permission . '-all');
473             $ownPermission = $user && $user->can($permission . '-own');
474             $ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
475             $isOwner = $user && $user->id === $ownable->$ownerField;
476             return ($allPermission || ($isOwner && $ownPermission));
477         }
478
479         // Handle abnormal create jointPermissions
480         if ($action === 'create') {
481             $action = $permission;
482         }
483
484         $hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
485         $this->clean();
486         return $hasAccess;
487     }
488
489     /**
490      * Checks if a user has the given permission for any items in the system.
491      * Can be passed an entity instance to filter on a specific type.
492      */
493     public function checkUserHasPermissionOnAnything(string $permission, ?string $entityClass = null): bool
494     {
495         $userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
496         $userId = $this->currentUser()->id;
497
498         $permissionQuery = JointPermission::query()
499             ->where('action', '=', $permission)
500             ->whereIn('role_id', $userRoleIds)
501             ->where(function (Builder $query) use ($userId) {
502                 $this->addJointHasPermissionCheck($query, $userId);
503             });
504
505         if (!is_null($entityClass)) {
506             $entityInstance = app($entityClass);
507             $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
508         }
509
510         $hasPermission = $permissionQuery->count() > 0;
511         $this->clean();
512         return $hasPermission;
513     }
514
515     /**
516      * The general query filter to remove all entities
517      * that the current user does not have access to.
518      */
519     protected function entityRestrictionQuery(Builder $query, string $action): Builder
520     {
521         $q = $query->where(function ($parentQuery) use ($action) {
522             $parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
523                 $permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
524                     ->where('action', '=', $action)
525                     ->where(function (Builder $query) {
526                         $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
527                     });
528             });
529         });
530
531         $this->clean();
532         return $q;
533     }
534
535     /**
536      * Limited the given entity query so that the query will only
537      * return items that the user has permission for the given ability.
538      */
539     public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
540     {
541         $this->clean();
542         return $query->where(function (Builder $parentQuery) use ($ability) {
543             $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
544                 $permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
545                     ->where('action', '=', $ability)
546                     ->where(function (Builder $query) {
547                         $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
548                     });
549             });
550         });
551     }
552
553     /**
554      * Extend the given page query to ensure draft items are not visible
555      * unless created by the given user.
556      */
557     public function enforceDraftVisibilityOnQuery(Builder $query): Builder
558     {
559         return $query->where(function (Builder $query) {
560             $query->where('draft', '=', false)
561                 ->orWhere(function (Builder $query) {
562                     $query->where('draft', '=', true)
563                         ->where('owned_by', '=', $this->currentUser()->id);
564                 });
565         });
566     }
567
568     /**
569      * Add restrictions for a generic entity.
570      */
571     public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder
572     {
573         if ($entity instanceof Page) {
574             // Prevent drafts being visible to others.
575             $this->enforceDraftVisibilityOnQuery($query);
576         }
577
578         return $this->entityRestrictionQuery($query, $action);
579     }
580
581     /**
582      * Filter items that have entities set as a polymorphic relation.
583      * @param Builder|\Illuminate\Database\Query\Builder $query
584      */
585     public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
586     {
587         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
588
589         $q = $query->where(function ($query) use ($tableDetails, $action) {
590             $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
591                 $permissionQuery->select(['role_id'])->from('joint_permissions')
592                     ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
593                     ->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
594                     ->where('action', '=', $action)
595                     ->whereIn('role_id', $this->getCurrentUserRoles())
596                     ->where(function (QueryBuilder $query) {
597                         $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
598                     });
599             });
600         });
601
602         $this->clean();
603         return $q;
604     }
605
606     /**
607      * Add conditions to a query to filter the selection to related entities
608      * where view permissions are granted.
609      */
610     public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
611     {
612         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
613         $morphClass = app($entityClass)->getMorphClass();
614
615         $q = $query->where(function ($query) use ($tableDetails, $morphClass) {
616             $query->where(function ($query) use (&$tableDetails, $morphClass) {
617                 $query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
618                     $permissionQuery->select('id')->from('joint_permissions')
619                         ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
620                         ->where('entity_type', '=', $morphClass)
621                         ->where('action', '=', 'view')
622                         ->whereIn('role_id', $this->getCurrentUserRoles())
623                         ->where(function (QueryBuilder $query) {
624                             $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
625                         });
626                 });
627             })->orWhere($tableDetails['entityIdColumn'], '=', 0);
628         });
629
630         $this->clean();
631         return $q;
632     }
633
634     /**
635      * Add the query for checking the given user id has permission
636      * within the join_permissions table.
637      * @param QueryBuilder|Builder $query
638      */
639     protected function addJointHasPermissionCheck($query, int $userIdToCheck)
640     {
641         $query->where('has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
642             $query->where('has_permission_own', '=', true)
643                 ->where('owned_by', '=', $userIdToCheck);
644         });
645     }
646
647     /**
648      * Get the current user
649      */
650     private function currentUser(): User
651     {
652         if (is_null($this->currentUserModel)) {
653             $this->currentUserModel = user();
654         }
655
656         return $this->currentUserModel;
657     }
658
659     /**
660      * Clean the cached user elements.
661      */
662     private function clean(): void
663     {
664         $this->currentUserModel = null;
665         $this->userRoles = null;
666     }
667 }