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