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