]> BookStack Code Mirror - bookstack/blob - app/Auth/Permissions/JointPermissionBuilder.php
Removed remaining dynamic action usages in joint permission queries
[bookstack] / app / Auth / Permissions / JointPermissionBuilder.php
1 <?php
2
3 namespace BookStack\Auth\Permissions;
4
5 use BookStack\Auth\Role;
6 use BookStack\Entities\Models\Book;
7 use BookStack\Entities\Models\BookChild;
8 use BookStack\Entities\Models\Bookshelf;
9 use BookStack\Entities\Models\Chapter;
10 use BookStack\Entities\Models\Entity;
11 use BookStack\Entities\Models\Page;
12 use Illuminate\Database\Eloquent\Builder;
13 use Illuminate\Database\Eloquent\Collection as EloquentCollection;
14 use Illuminate\Support\Facades\DB;
15
16 class JointPermissionBuilder
17 {
18     /**
19      * @var array<string, array<int, SimpleEntityData>>
20      */
21     protected $entityCache;
22
23     /**
24      * Re-generate all entity permission from scratch.
25      */
26     public function rebuildForAll()
27     {
28         JointPermission::query()->truncate();
29
30         // Get all roles (Should be the most limited dimension)
31         $roles = Role::query()->with('permissions')->get()->all();
32
33         // Chunk through all books
34         $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
35             $this->buildJointPermissionsForBooks($books, $roles);
36         });
37
38         // Chunk through all bookshelves
39         Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
40             ->chunk(50, function (EloquentCollection $shelves) use ($roles) {
41                 $this->createManyJointPermissions($shelves->all(), $roles);
42             });
43     }
44
45     /**
46      * Rebuild the entity jointPermissions for a particular entity.
47      */
48     public function rebuildForEntity(Entity $entity)
49     {
50         $entities = [$entity];
51         if ($entity instanceof Book) {
52             $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
53             $this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
54
55             return;
56         }
57
58         /** @var BookChild $entity */
59         if ($entity->book) {
60             $entities[] = $entity->book;
61         }
62
63         if ($entity instanceof Page && $entity->chapter_id) {
64             $entities[] = $entity->chapter;
65         }
66
67         if ($entity instanceof Chapter) {
68             foreach ($entity->pages as $page) {
69                 $entities[] = $page;
70             }
71         }
72
73         $this->buildJointPermissionsForEntities($entities);
74     }
75
76     /**
77      * Build the entity jointPermissions for a particular role.
78      */
79     public function rebuildForRole(Role $role)
80     {
81         $roles = [$role];
82         $role->jointPermissions()->delete();
83
84         // Chunk through all books
85         $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
86             $this->buildJointPermissionsForBooks($books, $roles);
87         });
88
89         // Chunk through all bookshelves
90         Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
91             ->chunk(50, function ($shelves) use ($roles) {
92                 $this->createManyJointPermissions($shelves->all(), $roles);
93             });
94     }
95
96     /**
97      * Prepare the local entity cache and ensure it's empty.
98      *
99      * @param SimpleEntityData[] $entities
100      */
101     protected function readyEntityCache(array $entities)
102     {
103         $this->entityCache = [];
104
105         foreach ($entities as $entity) {
106             if (!isset($this->entityCache[$entity->type])) {
107                 $this->entityCache[$entity->type] = [];
108             }
109
110             $this->entityCache[$entity->type][$entity->id] = $entity;
111         }
112     }
113
114     /**
115      * Get a book via ID, Checks local cache.
116      */
117     protected function getBook(int $bookId): SimpleEntityData
118     {
119         return $this->entityCache['book'][$bookId];
120     }
121
122     /**
123      * Get a chapter via ID, Checks local cache.
124      */
125     protected function getChapter(int $chapterId): SimpleEntityData
126     {
127         return $this->entityCache['chapter'][$chapterId];
128     }
129
130     /**
131      * Get a query for fetching a book with its children.
132      */
133     protected function bookFetchQuery(): Builder
134     {
135         return Book::query()->withTrashed()
136             ->select(['id', 'restricted', 'owned_by'])->with([
137                 'chapters' => function ($query) {
138                     $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
139                 },
140                 'pages' => function ($query) {
141                     $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
142                 },
143             ]);
144     }
145
146
147     /**
148      * Build joint permissions for the given book and role combinations.
149      */
150     protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
151     {
152         $entities = clone $books;
153
154         /** @var Book $book */
155         foreach ($books->all() as $book) {
156             foreach ($book->getRelation('chapters') as $chapter) {
157                 $entities->push($chapter);
158             }
159             foreach ($book->getRelation('pages') as $page) {
160                 $entities->push($page);
161             }
162         }
163
164         if ($deleteOld) {
165             $this->deleteManyJointPermissionsForEntities($entities->all());
166         }
167
168         $this->createManyJointPermissions($entities->all(), $roles);
169     }
170
171     /**
172      * Rebuild the entity jointPermissions for a collection of entities.
173      */
174     protected function buildJointPermissionsForEntities(array $entities)
175     {
176         $roles = Role::query()->get()->values()->all();
177         $this->deleteManyJointPermissionsForEntities($entities);
178         $this->createManyJointPermissions($entities, $roles);
179     }
180
181     /**
182      * Delete all the entity jointPermissions for a list of entities.
183      *
184      * @param Entity[] $entities
185      */
186     protected function deleteManyJointPermissionsForEntities(array $entities)
187     {
188         $simpleEntities = $this->entitiesToSimpleEntities($entities);
189         $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
190
191         DB::transaction(function () use ($idsByType) {
192             foreach ($idsByType as $type => $ids) {
193                 foreach (array_chunk($ids, 1000) as $idChunk) {
194                     DB::table('joint_permissions')
195                         ->where('entity_type', '=', $type)
196                         ->whereIn('entity_id', $idChunk)
197                         ->delete();
198                 }
199             }
200         });
201     }
202
203     /**
204      * @param Entity[] $entities
205      * @return SimpleEntityData[]
206      */
207     protected function entitiesToSimpleEntities(array $entities): array
208     {
209         $simpleEntities = [];
210
211         foreach ($entities as $entity) {
212             $attrs = $entity->getAttributes();
213             $simple = new SimpleEntityData();
214             $simple->id = $attrs['id'];
215             $simple->type = $entity->getMorphClass();
216             $simple->restricted = boolval($attrs['restricted'] ?? 0);
217             $simple->owned_by = $attrs['owned_by'] ?? 0;
218             $simple->book_id = $attrs['book_id'] ?? null;
219             $simple->chapter_id = $attrs['chapter_id'] ?? null;
220             $simpleEntities[] = $simple;
221         }
222
223         return $simpleEntities;
224     }
225
226     /**
227      * Create & Save entity jointPermissions for many entities and roles.
228      *
229      * @param Entity[] $entities
230      * @param Role[]   $roles
231      */
232     protected function createManyJointPermissions(array $originalEntities, array $roles)
233     {
234         $entities = $this->entitiesToSimpleEntities($originalEntities);
235         $this->readyEntityCache($entities);
236         $jointPermissions = [];
237
238         // Create a mapping of entity restricted statuses
239         $entityRestrictedMap = [];
240         foreach ($entities as $entity) {
241             $entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
242         }
243
244         // Fetch related entity permissions
245         $permissions = $this->getEntityPermissionsForEntities($entities);
246
247         // Create a mapping of explicit entity permissions
248         $permissionMap = [];
249         foreach ($permissions as $permission) {
250             $key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
251             $isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
252             $permissionMap[$key] = $isRestricted;
253         }
254
255         // Create a mapping of role permissions
256         $rolePermissionMap = [];
257         foreach ($roles as $role) {
258             foreach ($role->permissions as $permission) {
259                 $rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
260             }
261         }
262
263         // Create Joint Permission Data
264         foreach ($entities as $entity) {
265             foreach ($roles as $role) {
266                 foreach ($this->getActions($entity) as $action) {
267                     $jointPermissions[] = $this->createJointPermissionData(
268                         $entity,
269                         $role->getRawAttribute('id'),
270                         $action,
271                         $permissionMap,
272                         $rolePermissionMap,
273                         $role->system_name === 'admin'
274                     );
275                 }
276             }
277         }
278
279         DB::transaction(function () use ($jointPermissions) {
280             foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
281                 DB::table('joint_permissions')->insert($jointPermissionChunk);
282             }
283         });
284     }
285
286     /**
287      * From the given entity list, provide back a mapping of entity types to
288      * the ids of that given type. The type used is the DB morph class.
289      * @param SimpleEntityData[] $entities
290      * @return array<string, int[]>
291      */
292     protected function entitiesToTypeIdMap(array $entities): array
293     {
294         $idsByType = [];
295
296         foreach ($entities as $entity) {
297             if (!isset($idsByType[$entity->type])) {
298                 $idsByType[$entity->type] = [];
299             }
300
301             $idsByType[$entity->type][] = $entity->id;
302         }
303
304         return $idsByType;
305     }
306
307     /**
308      * Get the entity permissions for all the given entities
309      * @param SimpleEntityData[] $entities
310      * @return EntityPermission[]
311      */
312     protected function getEntityPermissionsForEntities(array $entities): array
313     {
314         $idsByType = $this->entitiesToTypeIdMap($entities);
315         $permissionFetch = EntityPermission::query();
316
317         foreach ($idsByType as $type => $ids) {
318             $permissionFetch->orWhere(function (Builder $query) use ($type, $ids) {
319                 $query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
320             });
321         }
322
323         return $permissionFetch->get()->all();
324     }
325
326     /**
327      * Get the actions related to an entity.
328      */
329     protected function getActions(SimpleEntityData $entity): array
330     {
331         $baseActions = ['view', 'update', 'delete'];
332
333         if ($entity->type === 'chapter' || $entity->type === 'book') {
334             $baseActions[] = 'page-create';
335         }
336
337         if ($entity->type === 'book') {
338             $baseActions[] = 'chapter-create';
339         }
340
341         return $baseActions;
342     }
343
344     /**
345      * Create entity permission data for an entity and role
346      * for a particular action.
347      */
348     protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, string $action, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
349     {
350         $permissionPrefix = (strpos($action, '-') === false ? ($entity->type . '-') : '') . $action;
351         $roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
352         $roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
353         $explodedAction = explode('-', $action);
354         $restrictionAction = end($explodedAction);
355
356         if ($isAdminRole) {
357             return $this->createJointPermissionDataArray($entity, $roleId, $action, true, true);
358         }
359
360         if ($entity->restricted) {
361             $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId, $restrictionAction);
362
363             return $this->createJointPermissionDataArray($entity, $roleId, $action, $hasAccess, $hasAccess);
364         }
365
366         if ($entity->type === 'book' || $entity->type === 'bookshelf') {
367             return $this->createJointPermissionDataArray($entity, $roleId, $action, $roleHasPermission, $roleHasPermissionOwn);
368         }
369
370         // For chapters and pages, Check if explicit permissions are set on the Book.
371         $book = $this->getBook($entity->book_id);
372         $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId, $restrictionAction);
373         $hasPermissiveAccessToParents = !$book->restricted;
374
375         // For pages with a chapter, Check if explicit permissions are set on the Chapter
376         if ($entity->type === 'page' && $entity->chapter_id !== 0) {
377             $chapter = $this->getChapter($entity->chapter_id);
378             $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
379             if ($chapter->restricted) {
380                 $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId, $restrictionAction);
381             }
382         }
383
384         return $this->createJointPermissionDataArray(
385             $entity,
386             $roleId,
387             $action,
388             ($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
389             ($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
390         );
391     }
392
393     /**
394      * Check for an active restriction in an entity map.
395      */
396     protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId, string $action): bool
397     {
398         $key = $entity->type . ':' . $entity->id . ':' . $roleId . ':' . $action;
399
400         return $entityMap[$key] ?? false;
401     }
402
403     /**
404      * Create an array of data with the information of an entity jointPermissions.
405      * Used to build data for bulk insertion.
406      */
407     protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, string $action, bool $permissionAll, bool $permissionOwn): array
408     {
409         return [
410             'action'             => $action,
411             'entity_id'          => $entity->id,
412             'entity_type'        => $entity->type,
413             'has_permission'     => $permissionAll,
414             'has_permission_own' => $permissionOwn,
415             'owned_by'           => $entity->owned_by,
416             'role_id'            => $roleId,
417         ];
418     }
419
420 }