]> BookStack Code Mirror - bookstack/blob - app/Permissions/PermissionApplicator.php
47f30c8f949376fd8fbdea3b33c765e980391513
[bookstack] / app / Permissions / PermissionApplicator.php
1 <?php
2
3 namespace BookStack\Permissions;
4
5 use BookStack\App\Model;
6 use BookStack\Entities\EntityProvider;
7 use BookStack\Entities\Models\Entity;
8 use BookStack\Entities\Models\Page;
9 use BookStack\Permissions\Models\EntityPermission;
10 use BookStack\Users\Models\OwnableInterface;
11 use BookStack\Users\Models\User;
12 use Illuminate\Database\Eloquent\Builder;
13 use Illuminate\Database\Query\Builder as QueryBuilder;
14 use Illuminate\Database\Query\JoinClause;
15 use InvalidArgumentException;
16
17 class PermissionApplicator
18 {
19     public function __construct(
20         protected ?User $user = null
21     ) {
22     }
23
24     /**
25      * Checks if an entity has a restriction set upon it.
26      */
27     public function checkOwnableUserAccess(Model&OwnableInterface $ownable, string|Permission $permission): bool
28     {
29         $permissionName = is_string($permission) ? $permission : $permission->value;
30         $explodedPermission = explode('-', $permissionName);
31         $action = $explodedPermission[1] ?? $explodedPermission[0];
32         $fullPermission = count($explodedPermission) > 1 ? $permissionName : $ownable->getMorphClass() . '-' . $permissionName;
33
34         $user = $this->currentUser();
35         $userRoleIds = $this->getCurrentUserRoleIds();
36
37         $allRolePermission = $user->can($fullPermission . '-all');
38         $ownRolePermission = $user->can($fullPermission . '-own');
39         $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
40         $ownerField = $ownable->getOwnerFieldName();
41         $ownableFieldVal = $ownable->getAttribute($ownerField);
42
43         if (is_null($ownableFieldVal)) {
44             throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
45         }
46
47         $isOwner = $user->id === $ownableFieldVal;
48         $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
49
50         // Handle non-entity-specific jointPermissions
51         if (in_array($explodedPermission[0], $nonJointPermissions)) {
52             return $hasRolePermission;
53         }
54
55         if (!($ownable instanceof Entity)) {
56             return false;
57         }
58
59         $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
60
61         return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
62     }
63
64     /**
65      * Check if there are permissions that are applicable for the given entity item, action and roles.
66      * Returns null when no entity permissions are in force.
67      */
68     protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
69     {
70         $this->ensureValidEntityAction($action);
71
72         return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
73     }
74
75     /**
76      * Checks if a user has the given permission for any items in the system.
77      * Can be passed an entity instance to filter on a specific type.
78      */
79     public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
80     {
81         $this->ensureValidEntityAction($action);
82
83         $permissionQuery = EntityPermission::query()
84             ->where($action, '=', true)
85             ->whereIn('role_id', $this->getCurrentUserRoleIds());
86
87         if (!empty($entityClass)) {
88             /** @var Entity $entityInstance */
89             $entityInstance = app()->make($entityClass);
90             $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
91         }
92
93         $hasPermission = $permissionQuery->count() > 0;
94
95         return $hasPermission;
96     }
97
98     /**
99      * Limit the given entity query so that the query will only
100      * return items that the user has view permission for.
101      */
102     public function restrictEntityQuery(Builder $query): Builder
103     {
104         return $query->where(function (Builder $parentQuery) {
105             $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
106                 $permissionQuery->select(['entity_id', 'entity_type'])
107                     ->selectRaw('max(owner_id) as owner_id')
108                     ->selectRaw('max(status) as status')
109                     ->whereIn('role_id', $this->getCurrentUserRoleIds())
110                     ->groupBy(['entity_type', 'entity_id'])
111                     ->havingRaw('(status IN (1, 3) or (owner_id = ? and status != 2))', [$this->currentUser()->id]);
112             });
113         });
114     }
115
116     /**
117      * Extend the given page query to ensure draft items are not visible
118      * unless created by the given user.
119      */
120     public function restrictDraftsOnPageQuery(Builder $query): Builder
121     {
122         return $query->where(function (Builder $query) {
123             $query->where('draft', '=', false)
124                 ->orWhere(function (Builder $query) {
125                     $query->where('draft', '=', true)
126                         ->where('owned_by', '=', $this->currentUser()->id);
127                 });
128         });
129     }
130
131     /**
132      * Filter items that have entities set as a polymorphic relation.
133      * For simplicity, this will not return results attached to draft pages.
134      * Draft pages should never really have related items though.
135      */
136     public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
137     {
138         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
139         $pageMorphClass = (new Page())->getMorphClass();
140
141         return $this->restrictEntityQuery($query)
142             ->where(function ($query) use ($tableDetails, $pageMorphClass) {
143                 /** @var Builder $query */
144                 $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
145                 ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
146                     $query->select('id')->from('pages')
147                         ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
148                         ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
149                         ->where('pages.draft', '=', false);
150                 });
151             });
152     }
153
154     /**
155      * Filter out items that have related entity relations where
156      * the entity is marked as deleted.
157      */
158     public function filterDeletedFromEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
159     {
160         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
161         $entityProvider = new EntityProvider();
162
163         $joinQuery = function ($query) use ($entityProvider) {
164             $first = true;
165             foreach ($entityProvider->all() as $entity) {
166                 /** @var Builder $query */
167                 $entityQuery = function ($query) use ($entity) {
168                     $query->select(['id', 'deleted_at'])
169                         ->selectRaw("'{$entity->getMorphClass()}' as type")
170                         ->from($entity->getTable())
171                         ->whereNotNull('deleted_at');
172                 };
173
174                 if ($first) {
175                     $entityQuery($query);
176                     $first = false;
177                 } else {
178                     $query->union($entityQuery);
179                 }
180             }
181         };
182
183         return $query->leftJoinSub($joinQuery, 'deletions', function (JoinClause $join) use ($tableDetails) {
184             $join->on($tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'], '=', 'deletions.id')
185                 ->on($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', 'deletions.type');
186         })->whereNull('deletions.deleted_at');
187     }
188
189     /**
190      * Add conditions to a query for a model that's a relation of a page, so only the model results
191      * on visible pages are returned by the query.
192      * Is effectively the same as "restrictEntityRelationQuery" but takes into account page drafts
193      * while not expecting a polymorphic relation, Just a simpler one-page-to-many-relations set-up.
194      */
195     public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
196     {
197         $fullPageIdColumn = $tableName . '.' . $pageIdColumn;
198         return $this->restrictEntityQuery($query)
199             ->where(function ($query) use ($fullPageIdColumn) {
200                 /** @var Builder $query */
201                 $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
202                     $query->select('id')->from('pages')
203                         ->whereColumn('pages.id', '=', $fullPageIdColumn)
204                         ->where('pages.draft', '=', false);
205                 })->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
206                     $query->select('id')->from('pages')
207                         ->whereColumn('pages.id', '=', $fullPageIdColumn)
208                         ->where('pages.draft', '=', true)
209                         ->where('pages.created_by', '=', $this->currentUser()->id);
210                 });
211             });
212     }
213
214     /**
215      * Get the current user.
216      */
217     protected function currentUser(): User
218     {
219         return $this->user ?? user();
220     }
221
222     /**
223      * Get the roles for the current logged-in user.
224      *
225      * @return int[]
226      */
227     protected function getCurrentUserRoleIds(): array
228     {
229         return $this->currentUser()->roles->pluck('id')->values()->all();
230     }
231
232     /**
233      * Ensure the given action is a valid and expected entity action.
234      * Throws an exception if invalid otherwise does nothing.
235      * @throws InvalidArgumentException
236      */
237     protected function ensureValidEntityAction(string $action): void
238     {
239         $allowed = Permission::genericForEntity();
240         foreach ($allowed as $permission) {
241             if ($permission->value === $action) {
242                 return;
243             }
244         }
245
246         throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
247     }
248 }