]> BookStack Code Mirror - bookstack/blob - app/Auth/Permissions/JointPermissionBuilder.php
Started new permission-caching/querying model
[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 /**
17  * Joint permissions provide a pre-query "cached" table of view permissions for all core entity
18  * types for all roles in the system. This class generates out that table for different scenarios.
19  */
20 class JointPermissionBuilder
21 {
22     /**
23      * Re-generate all entity permission from scratch.
24      */
25     public function rebuildForAll()
26     {
27         DB::table('entity_permissions_collapsed')->truncate();
28
29         // Chunk through all books
30         $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
31             $this->buildJointPermissionsForBooks($books);
32         });
33
34         // Chunk through all bookshelves
35         Bookshelf::query()->withTrashed()
36             ->select(['id', 'owned_by'])
37             ->chunk(50, function (EloquentCollection $shelves) {
38                 $this->generateCollapsedPermissions($shelves->all());
39             });
40     }
41
42     /**
43      * Rebuild the entity jointPermissions for a particular entity.
44      */
45     public function rebuildForEntity(Entity $entity)
46     {
47         $entities = [$entity];
48         if ($entity instanceof Book) {
49             $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
50             $this->buildJointPermissionsForBooks($books, true);
51
52             return;
53         }
54
55         /** @var BookChild $entity */
56         if ($entity->book) {
57             $entities[] = $entity->book;
58         }
59
60         if ($entity instanceof Page && $entity->chapter_id) {
61             $entities[] = $entity->chapter;
62         }
63
64         if ($entity instanceof Chapter) {
65             foreach ($entity->pages as $page) {
66                 $entities[] = $page;
67             }
68         }
69
70         $this->buildJointPermissionsForEntities($entities);
71     }
72
73     /**
74      * Get a query for fetching a book with its children.
75      */
76     protected function bookFetchQuery(): Builder
77     {
78         return Book::query()->withTrashed()
79             ->select(['id', 'owned_by'])->with([
80                 'chapters' => function ($query) {
81                     $query->withTrashed()->select(['id', 'owned_by', 'book_id']);
82                 },
83                 'pages' => function ($query) {
84                     $query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
85                 },
86             ]);
87     }
88
89     /**
90      * Build joint permissions for the given book and role combinations.
91      */
92     protected function buildJointPermissionsForBooks(EloquentCollection $books, bool $deleteOld = false)
93     {
94         $entities = clone $books;
95
96         /** @var Book $book */
97         foreach ($books->all() as $book) {
98             foreach ($book->getRelation('chapters') as $chapter) {
99                 $entities->push($chapter);
100             }
101             foreach ($book->getRelation('pages') as $page) {
102                 $entities->push($page);
103             }
104         }
105
106         if ($deleteOld) {
107             $this->deleteManyJointPermissionsForEntities($entities->all());
108         }
109
110         $this->generateCollapsedPermissions($entities->all());
111     }
112
113     /**
114      * Rebuild the entity jointPermissions for a collection of entities.
115      */
116     protected function buildJointPermissionsForEntities(array $entities)
117     {
118         $this->deleteManyJointPermissionsForEntities($entities);
119         $this->generateCollapsedPermissions($entities);
120     }
121
122     /**
123      * Delete all the entity jointPermissions for a list of entities.
124      *
125      * @param Entity[] $entities
126      */
127     protected function deleteManyJointPermissionsForEntities(array $entities)
128     {
129         $simpleEntities = $this->entitiesToSimpleEntities($entities);
130         $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
131
132         DB::transaction(function () use ($idsByType) {
133             foreach ($idsByType as $type => $ids) {
134                 foreach (array_chunk($ids, 1000) as $idChunk) {
135                     DB::table('entity_permissions_collapsed')
136                         ->where('entity_type', '=', $type)
137                         ->whereIn('entity_id', $idChunk)
138                         ->delete();
139                 }
140             }
141         });
142     }
143
144     /**
145      * @param Entity[] $entities
146      *
147      * @return SimpleEntityData[]
148      */
149     protected function entitiesToSimpleEntities(array $entities): array
150     {
151         $simpleEntities = [];
152
153         foreach ($entities as $entity) {
154             $attrs = $entity->getAttributes();
155             $simple = new SimpleEntityData();
156             $simple->id = $attrs['id'];
157             $simple->type = $entity->getMorphClass();
158             $simple->owned_by = $attrs['owned_by'] ?? 0;
159             $simple->book_id = $attrs['book_id'] ?? null;
160             $simple->chapter_id = $attrs['chapter_id'] ?? null;
161             $simpleEntities[] = $simple;
162         }
163
164         return $simpleEntities;
165     }
166
167     /**
168      * Create & Save collapsed entity permissions.
169      *
170      * @param Entity[] $originalEntities
171      */
172     protected function generateCollapsedPermissions(array $originalEntities)
173     {
174         $entities = $this->entitiesToSimpleEntities($originalEntities);
175         $jointPermissions = [];
176
177         // Fetch related entity permissions
178         $permissions = $this->getEntityPermissionsForEntities($entities);
179
180         // Create a mapping of explicit entity permissions
181         $permissionMap = new EntityPermissionMap($permissions);
182
183         // Create Joint Permission Data
184         foreach ($entities as $entity) {
185             array_push($jointPermissions, ...$this->createCollapsedPermissionData($entity, $permissionMap));
186         }
187
188         DB::transaction(function () use ($jointPermissions) {
189             foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
190                 DB::table('entity_permissions_collapsed')->insert($jointPermissionChunk);
191             }
192         });
193     }
194
195     /**
196      * Create collapsed permission data for the given entity using the given permission map.
197      */
198     protected function createCollapsedPermissionData(SimpleEntityData $entity, EntityPermissionMap $permissionMap): array
199     {
200         $chain = [
201             $entity->type . ':' . $entity->id,
202             $entity->chapter_id ? null : ('chapter:' . $entity->chapter_id),
203             $entity->book_id ? null : ('book:' . $entity->book_id),
204         ];
205
206         $permissionData = [];
207         $overridesApplied = [];
208
209         foreach ($chain as $entityTypeId) {
210             if ($entityTypeId === null) {
211                 continue;
212             }
213
214             $permissions = $permissionMap->getForEntity($entityTypeId);
215             foreach ($permissions as $permission) {
216                 $related = $permission->getAssignedType() . ':' . $permission->getAssignedTypeId();
217                 if (!isset($overridesApplied[$related])) {
218                     $permissionData[] = [
219                         'role_id' => $permission->role_id,
220                         'user_id' => $permission->user_id,
221                         'view' => $permission->view,
222                         'entity_type' => $entity->type,
223                         'entity_id' => $entity->id,
224                     ];
225                     $overridesApplied[$related] = true;
226                 }
227             }
228         }
229
230         return $permissionData;
231     }
232
233     /**
234      * From the given entity list, provide back a mapping of entity types to
235      * the ids of that given type. The type used is the DB morph class.
236      *
237      * @param SimpleEntityData[] $entities
238      *
239      * @return array<string, int[]>
240      */
241     protected function entitiesToTypeIdMap(array $entities): array
242     {
243         $idsByType = [];
244
245         foreach ($entities as $entity) {
246             if (!isset($idsByType[$entity->type])) {
247                 $idsByType[$entity->type] = [];
248             }
249
250             $idsByType[$entity->type][] = $entity->id;
251         }
252
253         return $idsByType;
254     }
255
256     /**
257      * Get the entity permissions for all the given entities.
258      *
259      * @param SimpleEntityData[] $entities
260      *
261      * @return EntityPermission[]
262      */
263     protected function getEntityPermissionsForEntities(array $entities): array
264     {
265         $idsByType = $this->entitiesToTypeIdMap($entities);
266         $permissionFetch = EntityPermission::query()
267             ->where(function (Builder $query) use ($idsByType) {
268                 foreach ($idsByType as $type => $ids) {
269                     $query->orWhere(function (Builder $query) use ($type, $ids) {
270                         $query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
271                     });
272                 }
273             });
274
275         return $permissionFetch->get()->all();
276     }
277 }