PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
← All changes | app/Modules/PermissionManager.php +331 -76 1.10.22.4.0 View file →
@@ -3,8 +3,10 @@
3 3 namespace FluentSupport\App\Modules;
4 4
5 5 use FluentSupport\App\Models\MailBox;
6 6 use FluentSupport\App\Services\Helper;
7 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
8 +use FluentSupport\Framework\Support\Arr;
7 9
8 10 /**
9 11 * PermissionManager class is responsible for getting/settings data related to permission
10 12 * @package FluentSupport\App\Modules
@@ -13,8 +15,15 @@
13 15 */
14 16
15 17 class PermissionManager
16 18 {
19 + const META_KEY = '_fluent_support_permissions';
20 +
21 + // Ticket visibility levels returned by resolveTicketVisibility()
22 + const VISIBILITY_ALL = 'all_tickets';
23 + const VISIBILITY_ASSIGNED_AND_UNASSIGNED = 'assigned_and_unassigned';
24 + const VISIBILITY_ASSIGNED_ONLY = 'assigned_only';
25 +
17 26 /**
18 27 * pluginPermissions method will return the list of permissions support by Fluent Support Plugin
19 28 * @return string[]
20 29 */
@@ -21,8 +30,9 @@
21 30 public static function pluginPermissions()
22 31 {
23 32 return [
24 33 'fst_view_dashboard',
34 + 'fst_view_tickets',
25 35 'fst_manage_own_tickets',
26 36 'fst_manage_unassigned_tickets',
27 37 'fst_manage_other_tickets',
28 38 'fst_delete_tickets',
@@ -42,9 +52,56 @@
42 52 ];
43 53 }
44 54
45 55 /**
46 - * attachPermissions method will add selected permission to the user
56 + * Primary permission check. Accepts a single permission string or an array (any match).
57 + *
58 + * @param string|array $permissions
59 + * @return bool
60 + */
61 + public static function userCan($permissions)
62 + {
63 + if (current_user_can('manage_options')) {
64 + return true;
65 + }
66 +
67 + $userPermissions = self::currentUserPermissions();
68 +
69 + if (!$userPermissions) {
70 + return false;
71 + }
72 +
73 + if (is_string($permissions)) {
74 + return in_array($permissions, $userPermissions);
75 + }
76 +
77 + if (is_array($permissions)) {
78 + foreach ($permissions as $permission) {
79 + if (in_array($permission, $userPermissions)) {
80 + return true;
81 + }
82 + }
83 + }
84 +
85 + return false;
86 + }
87 +
88 + /**
89 + * currentUserCan method will return whether a user has the selected permission or not.
90 + * Backward-compatible alias for userCan().
91 + *
92 + * @param $permission
93 + * @return bool
94 + */
95 + public static function currentUserCan($permission)
96 + {
97 + return self::userCan($permission);
98 + }
99 +
100 + /**
101 + * attachPermissions method will save selected permissions to user meta.
102 + * Also cleans up any legacy fst_* WordPress capabilities.
103 + *
47 104 * @param $user
48 105 * @param $permissions
49 106 * @return false|mixed
50 107 */
@@ -62,19 +119,38 @@
62 119 return $user;
63 120 }
64 121
65 122 $allPermissions = self::pluginPermissions();
66 - foreach ($allPermissions as $permission) {
67 - $user->remove_cap($permission);
123 +
124 + // Allowlist (never a denylist): only known plugin permissions may be written.
125 + // The privilege-ceiling invariant (an actor may only grant permissions it holds)
126 + // is enforced upstream by AgentPolicy, which limits agent mutations to
127 + // administrators — the only entry point that reaches this write.
128 + $permissions = array_values(array_intersect($allPermissions, $permissions));
129 +
130 + $exclusionRules = self::getExclusionRules();
131 + $permissions = self::applyExclusionRules($permissions, $exclusionRules);
132 +
133 + // Auto-grant fst_view_tickets when any manage, draft, or approve permission is present
134 + $manageOrDraftPermissions = [
135 + 'fst_manage_own_tickets',
136 + 'fst_manage_unassigned_tickets',
137 + 'fst_manage_other_tickets',
138 + 'fst_draft_reply',
139 + 'fst_approve_draft_reply',
140 + ];
141 +
142 + if (!empty(array_intersect($permissions, $manageOrDraftPermissions))
143 + && !in_array('fst_view_tickets', $permissions)) {
144 + $permissions[] = 'fst_view_tickets';
68 145 }
69 146
70 - $permissions = array_intersect($allPermissions, $permissions);
147 + // Store permissions in user meta
148 + update_user_meta($user->ID, self::META_KEY, array_values($permissions));
71 149
72 - $filterPermissionConditions = self::filterPermissionConditions();
73 - $permissions = self::filterPermissionsByConditions($permissions, $filterPermissionConditions);
74 -
75 - foreach ($permissions as $permission) {
76 - $user->add_cap($permission);
150 + // Clean up legacy WordPress capabilities
151 + foreach ($allPermissions as $cap) {
152 + $user->remove_cap($cap);
77 153 }
78 154
79 155 return $user;
80 156 }
@@ -79,17 +155,36 @@
79 155 return $user;
80 156 }
81 157
82 158 /**
83 - * Filters permissions based on specified conditions.
159 + * Clean removal of all Fluent Support permissions for a user.
84 160 *
161 + * @param int $userId
162 + * @return void
163 + */
164 + public static function detachPermissions($userId)
165 + {
166 + delete_user_meta($userId, self::META_KEY);
167 +
168 + // Clean up any legacy WordPress capabilities
169 + $user = get_user_by('ID', $userId);
170 + if ($user && !user_can($user, 'manage_options')) {
171 + foreach (self::pluginPermissions() as $cap) {
172 + $user->remove_cap($cap);
173 + }
174 + }
175 + }
176 +
177 + /**
178 + * Remove conflicting permissions based on exclusion rules.
179 + *
85 180 * @param array $permissions The array of permissions to filter.
86 - * @param array $conditions The conditions used for filtering.
181 + * @param array $rules Each key => value pair means: if key is present, remove value.
87 182 * @return array The filtered array of permissions.
88 183 */
89 - public static function filterPermissionsByConditions($permissions, $conditions)
184 + public static function applyExclusionRules($permissions, $rules)
90 185 {
91 - foreach ($conditions as $requiredKey => $removeKey) {
186 + foreach ($rules as $requiredKey => $removeKey) {
92 187 if (in_array($requiredKey, $permissions) && in_array($removeKey, $permissions)) {
93 188 unset($permissions[array_search($removeKey, $permissions)]);
94 189 }
95 190 }
@@ -96,24 +191,52 @@
96 191 return $permissions;
97 192 }
98 193
99 194 /**
100 - * Retrieves the permission filter conditions.
195 + * Get the mutual exclusion rules for permission assignment.
101 196 *
102 - * @return array The array of permission filter conditions.
197 + * @return array Each key => value pair means: if key is present, remove value.
103 198 */
104 - public static function filterPermissionConditions()
199 + public static function getExclusionRules()
105 200 {
201 + // Mutual exclusion rules applied when assigning permissions:
202 + // - If agent has any manage_*_tickets permission, remove fst_draft_reply
203 + // (draft-only mode is for agents who CANNOT manage tickets)
204 + // - If agent has fst_draft_reply, remove fst_approve_draft_reply
205 + // (draft-only agents should not approve their own drafts)
106 206 return [
107 207 'fst_manage_unassigned_tickets' => 'fst_draft_reply',
108 - 'fst_manage_other_tickets' => 'fst_draft_reply',
109 - 'fst_manage_own_tickets' => 'fst_draft_reply',
110 - 'fst_draft_reply' => 'fst_approve_draft_reply'
208 + 'fst_manage_other_tickets' => 'fst_draft_reply',
209 + 'fst_manage_own_tickets' => 'fst_draft_reply',
210 + 'fst_draft_reply' => 'fst_approve_draft_reply'
111 211 ];
112 212 }
113 213
114 214 /**
115 - * getUserPermissions method will get all permissions for a user
215 + * Get raw permissions from user meta.
216 + *
217 + * @param int|null $userId
218 + * @return array
219 + */
220 + public static function getMetaPermissions($userId = null)
221 + {
222 + if ($userId === null) {
223 + $userId = get_current_user_id();
224 + }
225 +
226 + if (!$userId) {
227 + return [];
228 + }
229 +
230 + $permissions = get_user_meta($userId, self::META_KEY, true);
231 +
232 + return is_array($permissions) ? $permissions : [];
233 + }
234 +
235 + /**
236 + * getUserPermissions method will get all permissions for a user.
237 + * Reads from user meta with legacy wp_capabilities fallback.
238 + *
116 239 * @param false $user
117 240 * @return array|string[]
118 241 */
119 242 public static function getUserPermissions($user = false)
@@ -129,13 +252,33 @@
129 252 $pluginPermission = self::pluginPermissions();
130 253
131 254 if ($user->has_cap('manage_options')) {
132 255 $pluginPermission[] = 'administrator';
133 - $pluginPermission = array_values(array_diff($pluginPermission, ['fst_draft_reply'])); //Remove draft reply permission from here
256 + $pluginPermission = array_values(array_diff($pluginPermission, ['fst_draft_reply']));
134 257 return $pluginPermission;
135 258 }
136 259
137 - return array_values(array_intersect(array_keys($user->allcaps), $pluginPermission));
260 + // Read from meta first
261 + $permissions = self::getMetaPermissions($user->ID);
262 +
263 + if (!empty($permissions)) {
264 + return array_values(array_intersect($permissions, $pluginPermission));
265 + }
266 +
267 + // Legacy fallback: read from wp_capabilities and migrate
268 + $legacyPermissions = array_values(array_intersect(array_keys($user->allcaps), $pluginPermission));
269 +
270 + if (!empty($legacyPermissions)) {
271 + // Migrate to meta
272 + update_user_meta($user->ID, self::META_KEY, $legacyPermissions);
273 +
274 + // Clean up legacy caps
275 + foreach ($legacyPermissions as $cap) {
276 + $user->remove_cap($cap);
277 + }
278 + }
279 +
280 + return $legacyPermissions;
138 281 }
139 282
140 283 /**
141 284 * currentUserPermissions method will return the permission of logged-in user
@@ -155,102 +298,158 @@
155 298 return $permissions;
156 299 }
157 300
158 301 /**
159 - * Retrieve the list of restricted business boxes for the current user.
302 + * Determine the WordPress capability string for menu registration.
303 + * Returns 'manage_options' for admins, the user's WP role for agents
304 + * with permissions, or empty string to hide the menu.
160 305 *
161 - * This method fetches the list of restricted business boxes from the metadata of the current user's agent profile.
162 - * If business box restrictions are enabled for the user, it returns the list of restricted business boxes; otherwise,
163 - * it returns an empty array.
306 + * @return string
307 + */
308 + public static function getMenuPermission()
309 + {
310 + if (current_user_can('manage_options')) {
311 + return 'manage_options';
312 + }
313 +
314 + $userId = get_current_user_id();
315 +
316 + if (!$userId) {
317 + return '';
318 + }
319 +
320 + $metaPermissions = self::getMetaPermissions($userId);
321 +
322 + // Legacy fallback: check wp_capabilities for fst_* caps
323 + if (empty($metaPermissions)) {
324 + $user = get_user_by('ID', $userId);
325 + if ($user) {
326 + $legacyPermissions = array_intersect(array_keys($user->allcaps), self::pluginPermissions());
327 + if (empty($legacyPermissions)) {
328 + return '';
329 + }
330 + } else {
331 + return '';
332 + }
333 + }
334 +
335 + $user = wp_get_current_user();
336 + $roles = array_values((array) $user->roles);
337 +
338 + return Arr::get($roles, 0, '');
339 + }
340 +
341 + /**
342 + * Get the mailbox IDs that the current agent is restricted from accessing.
164 343 *
165 - * @return array The list of restricted business boxes for the current user.
344 + * @return array Mailbox IDs the agent cannot access, or empty array if unrestricted.
166 345 */
167 - public static function currentUserRestrictedBusinessBoxes()
346 + public static function getRestrictedMailboxIds()
168 347 {
169 - $agent = Helper::getAgentByUserId();
170 - $restrictions = $agent->getMeta('agent_restrictions');
348 + return (new AgentTicketAccess())->getRestrictedMailboxIds();
171 349
172 - return isset($restrictions['businessBoxRestrictions']) ? $restrictions['restrictedBusinessBoxes'] : [];
350 + }
173 351
352 + /**
353 + * Whether the current user can perform mutating ticket actions (reply, close, reopen, assign, etc.).
354 + * Draft-only agents return false here — they can view tickets and create drafts but cannot publish.
355 + *
356 + * @return bool
357 + */
358 + public static function canManageTickets()
359 + {
360 + return self::userCan([
361 + 'fst_manage_own_tickets',
362 + 'fst_manage_unassigned_tickets',
363 + 'fst_manage_other_tickets'
364 + ]);
174 365 }
175 366
176 367 /**
177 - * currentUserCan method will return whether a user has the selected permission or not
178 - * @param $permission
368 + * Whether the current user can access ticket API routes at all (read or write).
369 + * Includes manage, merge, draft-only, and view-only agents.
370 + *
179 371 * @return bool
180 372 */
181 - public static function currentUserCan($permission)
373 + public static function canAccessTicketRoutes()
182 374 {
183 - if (current_user_can('manage_options')) {
184 - return true;
185 - }
186 -
187 - return current_user_can($permission);
375 + return self::userCan([
376 + 'fst_view_tickets',
377 + 'fst_manage_own_tickets',
378 + 'fst_manage_unassigned_tickets',
379 + 'fst_manage_other_tickets',
380 + 'fst_merge_tickets',
381 + 'fst_draft_reply'
382 + ]);
188 383 }
189 384
190 385 /**
191 - * currentUserTicketsPermissionLevel method will return the permission level for a user in tickets
192 - * @return string
386 + * Determine ticket visibility level from a permission set.
387 + *
388 + * Business rule: fst_view_tickets and fst_draft_reply get full visibility because
389 + * read-only and draft agents need to view any ticket, even though they cannot publish.
390 + *
391 + * @param array $permissions
392 + * @return string One of the VISIBILITY_* constants.
193 393 */
194 - public static function currentUserTicketsPermissionLevel()
394 + private static function resolveTicketVisibility(array $permissions)
195 395 {
196 - $permissions = self::currentUserPermissions();
396 + // Manage-level permissions take priority for visibility
397 + if (in_array('fst_manage_other_tickets', $permissions)) {
398 + return self::VISIBILITY_ALL;
399 + }
197 400
198 - if (in_array('fst_manage_other_tickets', $permissions)) {
199 - return 'all';
401 + if (in_array('fst_manage_unassigned_tickets', $permissions)) {
402 + return self::VISIBILITY_ASSIGNED_AND_UNASSIGNED;
200 403 }
201 404
202 - if (in_array('fst_draft_reply', $permissions) || in_array('fst_manage_unassigned_tickets', $permissions)) {
203 - return 'own_plus';
405 + if (in_array('fst_manage_own_tickets', $permissions)) {
406 + return self::VISIBILITY_ASSIGNED_ONLY;
204 407 }
205 408
206 - return 'own';
409 + // Non-manage roles (draft, view-only) can see all tickets but cannot modify
410 + if (in_array('fst_draft_reply', $permissions)
411 + || in_array('fst_view_tickets', $permissions)) {
412 + return self::VISIBILITY_ALL;
413 + }
414 +
415 + return self::VISIBILITY_ASSIGNED_ONLY;
207 416 }
208 417
209 418 /**
210 - * agentTicketPermissionLevel method will return the access level of an agent in tickets
419 + * currentTicketVisibility method will return the permission level for a user in tickets
420 + * @return string
421 + */
422 + public static function currentTicketVisibility()
423 + {
424 + $permissions = self::currentUserPermissions();
425 + return self::resolveTicketVisibility($permissions);
426 + }
427 +
428 + /**
429 + * getAgentTicketVisibility method will return the access level of an agent in tickets
211 430 * @param false $userId
212 431 * @return string
213 432 */
214 - public static function agentTicketPermissionLevel($userId = false)
433 + public static function getAgentTicketVisibility($userId = false)
215 434 {
216 - if(!$userId) {
435 + if (!$userId) {
217 436 $userId = get_current_user_id();
218 437 }
219 438
220 439 $permissions = self::getUserPermissions($userId);
221 440
222 - if (in_array('fst_manage_other_tickets', $permissions)) {
223 - return 'all';
224 - }
225 -
226 - if (in_array('fst_draft_reply', $permissions) || in_array('fst_manage_unassigned_tickets', $permissions)) {
227 - return 'own_plus';
228 - }
229 -
230 - return 'own';
441 + return self::resolveTicketVisibility($permissions);
231 442 }
232 443
233 444 /**
234 - * hasTicketPermission method will return whether the selected user has permission in selected ticket or not
445 + * canAccessTicket method will return whether the selected user has permission in selected ticket or not
235 446 * @param $ticket
236 447 * @return bool
237 448 */
238 - public static function hasTicketPermission($ticket)
449 + public static function canAccessTicket($ticket)
239 450 {
240 - $permissionLevel = self::currentUserTicketsPermissionLevel();
241 -
242 - if ($permissionLevel == 'all') {
243 - return true;
244 - }
245 -
246 - $agent = Helper::getAgentByUserId();
247 -
248 - if ($ticket->agent_id == $agent->id) {
249 - return true;
250 - }
251 -
252 - return !$ticket->agent_id && $permissionLevel == 'own_plus';
451 + return (new AgentTicketAccess())->currentAgentCanAccess($ticket);
253 452 }
254 453
255 454 /**
256 455 * getReadablePermissionGroups method will return the permission group as array
@@ -271,8 +470,9 @@
271 470 'fst_merge_tickets' => __('Merge Tickets', 'fluent-support'),
272 471 'fst_split_ticket' => __('Split Ticket', 'fluent-support'),
273 472 'fst_draft_reply' => __('Draft Reply', 'fluent-support'),
274 473 'fst_approve_draft_reply' => __('Approve Draft Reply', 'fluent-support'),
474 + 'fst_view_tickets' => __('View Tickets (Read Only)', 'fluent-support'),
275 475 ]
276 476 ],
277 477 [
278 478 'title' => __('Workflow Permissions', 'fluent-support'),
@@ -299,9 +499,64 @@
299 499 ]
300 500 ];
301 501 }
302 502
303 - public static function getBusinessBoxesForRestriction()
503 + public static function getMailboxesForRestriction()
304 504 {
305 505 return MailBox::select(['id', 'name'])->get();
506 + }
507 +
508 + /*
509 + |--------------------------------------------------------------------------
510 + | Deprecated Methods
511 + |--------------------------------------------------------------------------
512 + | These methods are kept for backward compatibility with third-party add-ons.
513 + | They delegate to the renamed replacements and will be removed in a future release.
514 + */
515 +
516 + /**
517 + * @deprecated Use currentTicketVisibility() instead.
518 + */
519 + public static function currentUserTicketsPermissionLevel()
520 + {
521 + _deprecated_function(__METHOD__, '2.0.5', 'PermissionManager::currentTicketVisibility()');
522 +
523 + return self::mapVisibilityToLegacy(self::currentTicketVisibility());
524 + }
525 +
526 + /**
527 + * @deprecated Use getAgentTicketVisibility() instead.
528 + */
529 + public static function agentTicketPermissionLevel($userId = false)
530 + {
531 + _deprecated_function(__METHOD__, '2.0.5', 'PermissionManager::getAgentTicketVisibility()');
532 +
533 + return self::mapVisibilityToLegacy(self::getAgentTicketVisibility($userId));
534 + }
535 +
536 + /**
537 + * @deprecated Use canAccessTicket() instead.
538 + */
539 + public static function hasTicketPermission($ticket)
540 + {
541 + _deprecated_function(__METHOD__, '2.0.5', 'PermissionManager::canAccessTicket()');
542 +
543 + return self::canAccessTicket($ticket);
544 + }
545 +
546 + /**
547 + * Map new VISIBILITY_* constants back to legacy string values.
548 + *
549 + * @param string $visibility
550 + * @return string 'all', 'own_plus', or 'own'
551 + */
552 + private static function mapVisibilityToLegacy($visibility)
553 + {
554 + $map = [
555 + self::VISIBILITY_ALL => 'all',
556 + self::VISIBILITY_ASSIGNED_AND_UNASSIGNED => 'own_plus',
557 + self::VISIBILITY_ASSIGNED_ONLY => 'own',
558 + ];
559 +
560 + return $map[$visibility] ?? 'own';
306 561 }
307 562 }