]> BookStack Code Mirror - bookstack/blob - app/Auth/Permissions/PermissionApplicator.php
Added joint_user_permissions handling to query system
[bookstack] / app / Auth / Permissions / PermissionApplicator.php
1 <?php
2
3 namespace BookStack\Auth\Permissions;
4
5 use BookStack\Auth\Role;
6 use BookStack\Auth\User;
7 use BookStack\Entities\Models\Chapter;
8 use BookStack\Entities\Models\Entity;
9 use BookStack\Entities\Models\Page;
10 use BookStack\Model;
11 use BookStack\Traits\HasCreatorAndUpdater;
12 use BookStack\Traits\HasOwner;
13 use Illuminate\Database\Eloquent\Builder;
14 use Illuminate\Database\Query\Builder as QueryBuilder;
15 use InvalidArgumentException;
16
17 class PermissionApplicator
18 {
19     /**
20      * Checks if an entity has a restriction set upon it.
21      *
22      * @param HasCreatorAndUpdater|HasOwner $ownable
23      */
24     public function checkOwnableUserAccess(Model $ownable, string $permission): bool
25     {
26         $explodedPermission = explode('-', $permission);
27         $action = $explodedPermission[1] ?? $explodedPermission[0];
28         $fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission;
29
30         $user = $this->currentUser();
31         $userRoleIds = $this->getCurrentUserRoleIds();
32
33         $allRolePermission = $user->can($fullPermission . '-all');
34         $ownRolePermission = $user->can($fullPermission . '-own');
35         $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
36         $ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
37         $ownableFieldVal = $ownable->getAttribute($ownerField);
38
39         if (is_null($ownableFieldVal)) {
40             throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
41         }
42
43         $isOwner = $user->id === $ownableFieldVal;
44         $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
45
46         // Handle non entity specific jointPermissions
47         if (in_array($explodedPermission[0], $nonJointPermissions)) {
48             return $hasRolePermission;
49         }
50
51         $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $user->id, $action);
52
53         return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
54     }
55
56     /**
57      * Check if there are permissions that are applicable for the given entity item, action and roles.
58      * Returns null when no entity permissions are in force.
59      */
60     protected function hasEntityPermission(Entity $entity, array $userRoleIds, int $userId, string $action): ?bool
61     {
62         $this->ensureValidEntityAction($action);
63
64         $adminRoleId = Role::getSystemRole('admin')->id;
65         if (in_array($adminRoleId, $userRoleIds)) {
66             return true;
67         }
68
69         // The chain order here is very important due to the fact we walk up the chain
70         // in the loop below. Earlier items in the chain have higher priority.
71         $chain = [$entity];
72         if ($entity instanceof Page && $entity->chapter_id) {
73             $chain[] = $entity->chapter;
74         }
75
76         if ($entity instanceof Page || $entity instanceof Chapter) {
77             $chain[] = $entity->book;
78         }
79
80         foreach ($chain as $currentEntity) {
81             $relevantPermissions = $currentEntity->permissions()
82                 ->where(function (Builder $query) use ($userRoleIds, $userId) {
83                     $query->whereIn('role_id', $userRoleIds)
84                     ->orWhere('user_id', '=', $userId)
85                     ->orWhere(function (Builder $query) {
86                         $query->whereNull(['role_id', 'user_id']);
87                     });
88                 })
89                 ->get(['role_id', 'user_id', $action])
90                 ->all();
91
92             // Permissions work on specificity, in order of:
93             // 1. User-specific permissions
94             // 2. Role-specific permissions
95             // 3. Fallback-specific permissions
96             // For role permissions, the system tries to be fairly permissive, in that if the user has two roles,
97             // one lacking and one permitting an action, they will be permitted.
98
99             // If the default is set, we have to return something here.
100             $allowedById = [];
101             foreach ($relevantPermissions as $permission) {
102                 $allowedById[($permission->role_id ?? '') . ':' . ($permission->user_id ?? '')] = $permission->$action;
103             }
104
105             // Continue up the chain if no applicable entity permission overrides.
106             if (empty($allowedById)) {
107                 continue;
108             }
109
110             // If we have user-specific permissions set, return the status of that
111             // since it's the most specific possible.
112             $userKey = ':' . $userId;
113             if (isset($allowedById[$userKey])) {
114                 return $allowedById[$userKey];
115             }
116
117             // If we have role-specific permissions set, allow if any of those
118             // role permissions allow access.
119             $hasDefault = isset($allowedById[':']);
120             if (!$hasDefault || count($allowedById) > 1) {
121                 foreach ($allowedById as $key => $allowed) {
122                     if ($key !== ':' && $allowed) {
123                         return true;
124                     }
125                 }
126                 return false;
127             }
128
129             // Otherwise, return the default "Other roles" fallback value.
130             return $allowedById[':'];
131         }
132
133         return null;
134     }
135
136     /**
137      * Checks if a user has the given permission for any items in the system.
138      * Can be passed an entity instance to filter on a specific type.
139      */
140     public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
141     {
142         $this->ensureValidEntityAction($action);
143
144         $permissionQuery = EntityPermission::query()
145             ->where($action, '=', true)
146             ->where(function (Builder $query) {
147                 $query->whereIn('role_id', $this->getCurrentUserRoleIds())
148                 ->orWhere('user_id', '=', $this->currentUser()->id);
149             });
150
151         if (!empty($entityClass)) {
152             /** @var Entity $entityInstance */
153             $entityInstance = app()->make($entityClass);
154             $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
155         }
156
157         $hasPermission = $permissionQuery->count() > 0;
158
159         return $hasPermission;
160     }
161
162     /**
163      * Limit the given entity query so that the query will only
164      * return items that the user has view permission for.
165      */
166     public function restrictEntityQuery(Builder $query): Builder
167     {
168         return $query->where(function (Builder $parentQuery) {
169             $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
170                 $permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds())
171                     ->where(function (Builder $query) {
172                         $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
173                     });
174             })->orWhereHas('jointUserPermissions', function (Builder $query) {
175                 $query->where('user_id', '=', $this->currentUser()->id)->where('has_permission', '=', true);
176             });
177         })->whereDoesntHave('jointUserPermissions', function (Builder $query) {
178             $query->where('user_id', '=', $this->currentUser()->id)->where('has_permission', '=', false);
179         });
180     }
181
182     /**
183      * Extend the given page query to ensure draft items are not visible
184      * unless created by the given user.
185      */
186     public function restrictDraftsOnPageQuery(Builder $query): Builder
187     {
188         return $query->where(function (Builder $query) {
189             $query->where('draft', '=', false)
190                 ->orWhere(function (Builder $query) {
191                     $query->where('draft', '=', true)
192                         ->where('owned_by', '=', $this->currentUser()->id);
193                 });
194         });
195     }
196
197     /**
198      * Filter items that have entities set as a polymorphic relation.
199      * For simplicity, this will not return results attached to draft pages.
200      * Draft pages should never really have related items though.
201      *
202      * @param Builder|QueryBuilder $query
203      */
204     public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
205     {
206         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
207         $pageMorphClass = (new Page())->getMorphClass();
208
209         $q = $query->where(function ($query) use ($tableDetails) {
210             $query->whereExists(function ($permissionQuery) use ($tableDetails) {
211                 /** @var Builder $permissionQuery */
212                 $permissionQuery->select(['role_id'])->from('joint_permissions')
213                     ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
214                     ->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
215                     ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
216                     ->where(function (QueryBuilder $query) {
217                         $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
218                     });
219             })->orWhereExists(function ($permissionQuery) use ($tableDetails) {
220                 /** @var Builder $permissionQuery */
221                 $permissionQuery->select(['user_id'])->from('joint_user_permissions')
222                     ->whereColumn('joint_user_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
223                     ->whereColumn('joint_user_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
224                     ->where('joint_user_permissions.user_id', '=', $this->currentUser()->id)
225                     ->where('joint_user_permissions.has_permission', '=', true);
226             });
227         })->whereNotExists(function ($query) use ($tableDetails) {
228             $query->select(['user_id'])->from('joint_user_permissions')
229                 ->whereColumn('joint_user_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
230                 ->whereColumn('joint_user_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
231                 ->where('joint_user_permissions.user_id', '=', $this->currentUser()->id)
232                 ->where('joint_user_permissions.has_permission', '=', false);
233         })->where(function ($query) use ($tableDetails, $pageMorphClass) {
234             /** @var Builder $query */
235             $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
236                 ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
237                     $query->select('id')->from('pages')
238                         ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
239                         ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
240                         ->where('pages.draft', '=', false);
241                 });
242         });
243
244         return $q;
245     }
246
247     /**
248      * Add conditions to a query for a model that's a relation of a page, so only the model results
249      * on visible pages are returned by the query.
250      * Is effectively the same as "restrictEntityRelationQuery" but takes into account page drafts
251      * while not expecting a polymorphic relation, Just a simpler one-page-to-many-relations set-up.
252      */
253     public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
254     {
255         $fullPageIdColumn = $tableName . '.' . $pageIdColumn;
256         $morphClass = (new Page())->getMorphClass();
257
258         $existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) {
259             /** @var Builder $permissionQuery */
260             $permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
261                 ->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn)
262                 ->where('joint_permissions.entity_type', '=', $morphClass)
263                 ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
264                 ->where(function (QueryBuilder $query) {
265                     $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
266                 });
267         };
268
269         $userExistsQuery = function ($hasPermission) use ($fullPageIdColumn, $morphClass) {
270             return function ($permissionQuery) use ($fullPageIdColumn, $morphClass) {
271                 /** @var Builder $permissionQuery */
272                 $permissionQuery->select('joint_user_permissions.user_id')->from('joint_user_permissions')
273                     ->whereColumn('joint_user_permissions.entity_id', '=', $fullPageIdColumn)
274                     ->where('joint_user_permissions.entity_type', '=', $morphClass)
275                     ->where('joint_user_permissions.user_id', $this->currentUser()->id)
276                     ->where('has_permission', '=', true);
277             };
278         };
279
280         $q = $query->where(function ($query) use ($existsQuery, $userExistsQuery, $fullPageIdColumn) {
281             $query->whereExists($existsQuery)
282                 ->orWhereExists($userExistsQuery(true))
283                 ->orWhere($fullPageIdColumn, '=', 0);
284         })->whereNotExists($userExistsQuery(false));
285
286         // Prevent visibility of non-owned draft pages
287         $q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
288             $query->select('id')->from('pages')
289                 ->whereColumn('pages.id', '=', $fullPageIdColumn)
290                 ->where(function (QueryBuilder $query) {
291                     $query->where('pages.draft', '=', false)
292                         ->orWhere('pages.owned_by', '=', $this->currentUser()->id);
293                 });
294         });
295
296         return $q;
297     }
298
299     /**
300      * Add the query for checking the given user id has permission
301      * within the join_permissions table.
302      *
303      * @param QueryBuilder|Builder $query
304      */
305     protected function addJointHasPermissionCheck($query, int $userIdToCheck)
306     {
307         $query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
308             $query->where('joint_permissions.has_permission_own', '=', true)
309                 ->where('joint_permissions.owned_by', '=', $userIdToCheck);
310         });
311     }
312
313     /**
314      * Get the current user.
315      */
316     protected function currentUser(): User
317     {
318         return user();
319     }
320
321     /**
322      * Get the roles for the current logged-in user.
323      *
324      * @return int[]
325      */
326     protected function getCurrentUserRoleIds(): array
327     {
328         if (auth()->guest()) {
329             return [Role::getSystemRole('public')->id];
330         }
331
332         return $this->currentUser()->roles->pluck('id')->values()->all();
333     }
334
335     /**
336      * Ensure the given action is a valid and expected entity action.
337      * Throws an exception if invalid otherwise does nothing.
338      * @throws InvalidArgumentException
339      */
340     protected function ensureValidEntityAction(string $action): void
341     {
342         if (!in_array($action, EntityPermission::PERMISSIONS)) {
343             throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
344         }
345     }
346 }