3 namespace BookStack\Auth\Permissions;
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;
16 class JointPermissionBuilder
19 * @var array<string, array<int, SimpleEntityData>>
21 protected $entityCache;
24 * Re-generate all entity permission from scratch.
26 public function rebuildForAll()
28 JointPermission::query()->truncate();
30 // Get all roles (Should be the most limited dimension)
31 $roles = Role::query()->with('permissions')->get()->all();
33 // Chunk through all books
34 $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
35 $this->buildJointPermissionsForBooks($books, $roles);
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);
46 * Rebuild the entity jointPermissions for a particular entity.
48 public function rebuildForEntity(Entity $entity)
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);
58 /** @var BookChild $entity */
60 $entities[] = $entity->book;
63 if ($entity instanceof Page && $entity->chapter_id) {
64 $entities[] = $entity->chapter;
67 if ($entity instanceof Chapter) {
68 foreach ($entity->pages as $page) {
73 $this->buildJointPermissionsForEntities($entities);
77 * Build the entity jointPermissions for a particular role.
79 public function rebuildForRole(Role $role)
82 $role->jointPermissions()->delete();
84 // Chunk through all books
85 $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
86 $this->buildJointPermissionsForBooks($books, $roles);
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);
97 * Prepare the local entity cache and ensure it's empty.
99 * @param SimpleEntityData[] $entities
101 protected function readyEntityCache(array $entities)
103 $this->entityCache = [];
105 foreach ($entities as $entity) {
106 if (!isset($this->entityCache[$entity->type])) {
107 $this->entityCache[$entity->type] = [];
110 $this->entityCache[$entity->type][$entity->id] = $entity;
115 * Get a book via ID, Checks local cache.
117 protected function getBook(int $bookId): SimpleEntityData
119 return $this->entityCache['book'][$bookId];
123 * Get a chapter via ID, Checks local cache.
125 protected function getChapter(int $chapterId): SimpleEntityData
127 return $this->entityCache['chapter'][$chapterId];
131 * Get a query for fetching a book with its children.
133 protected function bookFetchQuery(): Builder
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']);
140 'pages' => function ($query) {
141 $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
148 * Build joint permissions for the given book and role combinations.
150 protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
152 $entities = clone $books;
154 /** @var Book $book */
155 foreach ($books->all() as $book) {
156 foreach ($book->getRelation('chapters') as $chapter) {
157 $entities->push($chapter);
159 foreach ($book->getRelation('pages') as $page) {
160 $entities->push($page);
165 $this->deleteManyJointPermissionsForEntities($entities->all());
168 $this->createManyJointPermissions($entities->all(), $roles);
172 * Rebuild the entity jointPermissions for a collection of entities.
174 protected function buildJointPermissionsForEntities(array $entities)
176 $roles = Role::query()->get()->values()->all();
177 $this->deleteManyJointPermissionsForEntities($entities);
178 $this->createManyJointPermissions($entities, $roles);
182 * Delete all the entity jointPermissions for a list of entities.
184 * @param Entity[] $entities
186 protected function deleteManyJointPermissionsForEntities(array $entities)
188 $simpleEntities = $this->entitiesToSimpleEntities($entities);
189 $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
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)
204 * @param Entity[] $entities
205 * @return SimpleEntityData[]
207 protected function entitiesToSimpleEntities(array $entities): array
209 $simpleEntities = [];
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;
223 return $simpleEntities;
227 * Create & Save entity jointPermissions for many entities and roles.
229 * @param Entity[] $entities
230 * @param Role[] $roles
232 protected function createManyJointPermissions(array $originalEntities, array $roles)
234 $entities = $this->entitiesToSimpleEntities($originalEntities);
235 $this->readyEntityCache($entities);
236 $jointPermissions = [];
238 // Create a mapping of entity restricted statuses
239 $entityRestrictedMap = [];
240 foreach ($entities as $entity) {
241 $entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
244 // Fetch related entity permissions
245 $permissions = $this->getEntityPermissionsForEntities($entities);
247 // Create a mapping of explicit entity permissions
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;
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;
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(
269 $role->getRawAttribute('id'),
273 $role->system_name === 'admin'
279 DB::transaction(function () use ($jointPermissions) {
280 foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
281 DB::table('joint_permissions')->insert($jointPermissionChunk);
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[]>
292 protected function entitiesToTypeIdMap(array $entities): array
296 foreach ($entities as $entity) {
297 if (!isset($idsByType[$entity->type])) {
298 $idsByType[$entity->type] = [];
301 $idsByType[$entity->type][] = $entity->id;
308 * Get the entity permissions for all the given entities
309 * @param SimpleEntityData[] $entities
310 * @return EntityPermission[]
312 protected function getEntityPermissionsForEntities(array $entities): array
314 $idsByType = $this->entitiesToTypeIdMap($entities);
315 $permissionFetch = EntityPermission::query();
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);
323 return $permissionFetch->get()->all();
327 * Get the actions related to an entity.
329 protected function getActions(SimpleEntityData $entity): array
331 $baseActions = ['view', 'update', 'delete'];
333 if ($entity->type === 'chapter' || $entity->type === 'book') {
334 $baseActions[] = 'page-create';
337 if ($entity->type === 'book') {
338 $baseActions[] = 'chapter-create';
345 * Create entity permission data for an entity and role
346 * for a particular action.
348 protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, string $action, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
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);
357 return $this->createJointPermissionDataArray($entity, $roleId, $action, true, true);
360 if ($entity->restricted) {
361 $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId, $restrictionAction);
363 return $this->createJointPermissionDataArray($entity, $roleId, $action, $hasAccess, $hasAccess);
366 if ($entity->type === 'book' || $entity->type === 'bookshelf') {
367 return $this->createJointPermissionDataArray($entity, $roleId, $action, $roleHasPermission, $roleHasPermissionOwn);
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;
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);
384 return $this->createJointPermissionDataArray(
388 ($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
389 ($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
394 * Check for an active restriction in an entity map.
396 protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId, string $action): bool
398 $key = $entity->type . ':' . $entity->id . ':' . $roleId . ':' . $action;
400 return $entityMap[$key] ?? false;
404 * Create an array of data with the information of an entity jointPermissions.
405 * Used to build data for bulk insertion.
407 protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, string $action, bool $permissionAll, bool $permissionOwn): array
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,