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;
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.
20 class JointPermissionBuilder
23 * Re-generate all entity permission from scratch.
25 public function rebuildForAll()
27 JointPermission::query()->truncate();
29 // Get all roles (Should be the most limited dimension)
30 $roles = Role::query()->with('permissions')->get()->all();
32 // Chunk through all books
33 $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
34 $this->buildJointPermissionsForBooks($books, $roles);
37 // Chunk through all bookshelves
38 Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
39 ->chunk(50, function (EloquentCollection $shelves) use ($roles) {
40 $this->createManyJointPermissions($shelves->all(), $roles);
45 * Rebuild the entity jointPermissions for a particular entity.
47 public function rebuildForEntity(Entity $entity)
49 $entities = [$entity];
50 if ($entity instanceof Book) {
51 $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
52 $this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
57 /** @var BookChild $entity */
59 $entities[] = $entity->book;
62 if ($entity instanceof Page && $entity->chapter_id) {
63 $entities[] = $entity->chapter;
66 if ($entity instanceof Chapter) {
67 foreach ($entity->pages as $page) {
72 $this->buildJointPermissionsForEntities($entities);
76 * Build the entity jointPermissions for a particular role.
78 public function rebuildForRole(Role $role)
81 $role->jointPermissions()->delete();
82 $role->load('permissions');
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', 'owned_by'])
91 ->chunk(50, function ($shelves) use ($roles) {
92 $this->createManyJointPermissions($shelves->all(), $roles);
97 * Get a query for fetching a book with its children.
99 protected function bookFetchQuery(): Builder
101 return Book::query()->withTrashed()
102 ->select(['id', 'owned_by'])->with([
103 'chapters' => function ($query) {
104 $query->withTrashed()->select(['id', 'owned_by', 'book_id']);
106 'pages' => function ($query) {
107 $query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
113 * Build joint permissions for the given book and role combinations.
115 protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
117 $entities = clone $books;
119 /** @var Book $book */
120 foreach ($books->all() as $book) {
121 foreach ($book->getRelation('chapters') as $chapter) {
122 $entities->push($chapter);
124 foreach ($book->getRelation('pages') as $page) {
125 $entities->push($page);
130 $this->deleteManyJointPermissionsForEntities($entities->all());
133 $this->createManyJointPermissions($entities->all(), $roles);
137 * Rebuild the entity jointPermissions for a collection of entities.
139 protected function buildJointPermissionsForEntities(array $entities)
141 $roles = Role::query()->get()->values()->all();
142 $this->deleteManyJointPermissionsForEntities($entities);
143 $this->createManyJointPermissions($entities, $roles);
147 * Delete all the entity jointPermissions for a list of entities.
149 * @param Entity[] $entities
151 protected function deleteManyJointPermissionsForEntities(array $entities)
153 $simpleEntities = $this->entitiesToSimpleEntities($entities);
154 $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
156 DB::transaction(function () use ($idsByType) {
157 foreach ($idsByType as $type => $ids) {
158 foreach (array_chunk($ids, 1000) as $idChunk) {
159 DB::table('joint_permissions')
160 ->where('entity_type', '=', $type)
161 ->whereIn('entity_id', $idChunk)
169 * @param Entity[] $entities
171 * @return SimpleEntityData[]
173 protected function entitiesToSimpleEntities(array $entities): array
175 $simpleEntities = [];
177 foreach ($entities as $entity) {
178 $simple = SimpleEntityData::fromEntity($entity);
179 $simpleEntities[] = $simple;
182 return $simpleEntities;
186 * Create & Save entity jointPermissions for many entities and roles.
188 * @param Entity[] $originalEntities
189 * @param Role[] $roles
191 protected function createManyJointPermissions(array $originalEntities, array $roles)
193 $entities = $this->entitiesToSimpleEntities($originalEntities);
194 $jointPermissions = [];
196 // Fetch related entity permissions
197 $permissions = new MassEntityPermissionEvaluator($entities, 'view');
199 // Create a mapping of role permissions
200 $rolePermissionMap = [];
201 foreach ($roles as $role) {
202 foreach ($role->permissions as $permission) {
203 $rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
207 // Create Joint Permission Data
208 foreach ($entities as $entity) {
209 foreach ($roles as $role) {
210 $jp = $this->createJointPermissionData(
212 $role->getRawAttribute('id'),
215 $role->system_name === 'admin'
217 $jointPermissions[] = $jp;
221 DB::transaction(function () use ($jointPermissions) {
222 foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
223 DB::table('joint_permissions')->insert($jointPermissionChunk);
229 * From the given entity list, provide back a mapping of entity types to
230 * the ids of that given type. The type used is the DB morph class.
232 * @param SimpleEntityData[] $entities
234 * @return array<string, int[]>
236 protected function entitiesToTypeIdMap(array $entities): array
240 foreach ($entities as $entity) {
241 if (!isset($idsByType[$entity->type])) {
242 $idsByType[$entity->type] = [];
245 $idsByType[$entity->type][] = $entity->id;
252 * Create entity permission data for an entity and role
253 * for a particular action.
255 protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
257 // Ensure system admin role retains permissions
259 return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true);
262 // Return evaluated entity permission status if it has an affect.
263 $entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
264 if ($entityPermissionStatus !== null) {
265 return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false);
268 // Otherwise default to the role-level permissions
269 $permissionPrefix = $entity->type . '-view';
270 $roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
271 $roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
272 $status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
273 return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn);
277 * Create an array of data with the information of an entity jointPermissions.
278 * Used to build data for bulk insertion.
280 protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array
282 $ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by);
285 'entity_id' => $entity->id,
286 'entity_type' => $entity->type,
287 'role_id' => $roleId,
288 'status' => $permissionStatus,
289 'owner_id' => $ownPermissionActive ? $entity->owned_by : null,