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