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 +378 -48 1.5.52.4.0 View file →
@@ -1,9 +1,12 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Modules;
4 4
5 +use FluentSupport\App\Models\MailBox;
5 6 use FluentSupport\App\Services\Helper;
7 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
8 +use FluentSupport\Framework\Support\Arr;
6 9
7 10 /**
8 11 * PermissionManager class is responsible for getting/settings data related to permission
9 12 * @package FluentSupport\App\Modules
@@ -12,8 +15,15 @@
12 15 */
13 16
14 17 class PermissionManager
15 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 +
16 26 /**
17 27 * pluginPermissions method will return the list of permissions support by Fluent Support Plugin
18 28 * @return string[]
19 29 */
@@ -20,12 +30,14 @@
20 30 public static function pluginPermissions()
21 31 {
22 32 return [
23 33 'fst_view_dashboard',
34 + 'fst_view_tickets',
24 35 'fst_manage_own_tickets',
25 36 'fst_manage_unassigned_tickets',
26 37 'fst_manage_other_tickets',
27 38 'fst_delete_tickets',
39 + 'fst_assign_agents',
28 40 'fst_manage_settings',
29 41 'fst_sensitive_data',
30 42 'fst_manage_workflows',
31 43 'fst_run_workflows',
@@ -30,14 +42,66 @@
30 42 'fst_manage_workflows',
31 43 'fst_run_workflows',
32 44 'fst_view_all_reports',
33 45 'fst_manage_saved_replies',
34 - 'fst_view_activity_logs'
46 + 'fst_view_activity_logs',
47 + 'fst_merge_tickets',
48 + 'fst_split_ticket',
49 + 'fst_agent_today_performance',
50 + 'fst_draft_reply',
51 + 'fst_approve_draft_reply'
35 52 ];
36 53 }
37 54
38 55 /**
39 - * 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 + *
40 104 * @param $user
41 105 * @param $permissions
42 106 * @return false|mixed
43 107 */
@@ -55,16 +119,38 @@
55 119 return $user;
56 120 }
57 121
58 122 $allPermissions = self::pluginPermissions();
59 - foreach ($allPermissions as $permission) {
60 - $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';
61 145 }
62 146
63 - $permissions = array_intersect($allPermissions, $permissions);
147 + // Store permissions in user meta
148 + update_user_meta($user->ID, self::META_KEY, array_values($permissions));
64 149
65 - foreach ($permissions as $permission) {
66 - $user->add_cap($permission);
150 + // Clean up legacy WordPress capabilities
151 + foreach ($allPermissions as $cap) {
152 + $user->remove_cap($cap);
67 153 }
68 154
69 155 return $user;
70 156 }
@@ -69,9 +155,88 @@
69 155 return $user;
70 156 }
71 157
72 158 /**
73 - * getUserPermissions method will get all permissions for a user
159 + * Clean removal of all Fluent Support permissions for a user.
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 + *
180 + * @param array $permissions The array of permissions to filter.
181 + * @param array $rules Each key => value pair means: if key is present, remove value.
182 + * @return array The filtered array of permissions.
183 + */
184 + public static function applyExclusionRules($permissions, $rules)
185 + {
186 + foreach ($rules as $requiredKey => $removeKey) {
187 + if (in_array($requiredKey, $permissions) && in_array($removeKey, $permissions)) {
188 + unset($permissions[array_search($removeKey, $permissions)]);
189 + }
190 + }
191 + return $permissions;
192 + }
193 +
194 + /**
195 + * Get the mutual exclusion rules for permission assignment.
196 + *
197 + * @return array Each key => value pair means: if key is present, remove value.
198 + */
199 + public static function getExclusionRules()
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)
206 + return [
207 + 'fst_manage_unassigned_tickets' => 'fst_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'
211 + ];
212 + }
213 +
214 + /**
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 + *
74 239 * @param false $user
75 240 * @return array|string[]
76 241 */
77 242 public static function getUserPermissions($user = false)
@@ -87,12 +252,33 @@
87 252 $pluginPermission = self::pluginPermissions();
88 253
89 254 if ($user->has_cap('manage_options')) {
90 255 $pluginPermission[] = 'administrator';
256 + $pluginPermission = array_values(array_diff($pluginPermission, ['fst_draft_reply']));
91 257 return $pluginPermission;
92 258 }
93 259
94 - 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;
95 281 }
96 282
97 283 /**
98 284 * currentUserPermissions method will return the permission of logged-in user
@@ -112,81 +298,158 @@
112 298 return $permissions;
113 299 }
114 300
115 301 /**
116 - * currentUserCan method will return whether a user has the selected permission or not
117 - * @param $permission
118 - * @return bool
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.
305 + *
306 + * @return string
119 307 */
120 - public static function currentUserCan($permission)
308 + public static function getMenuPermission()
121 309 {
122 310 if (current_user_can('manage_options')) {
123 - return true;
311 + return 'manage_options';
124 312 }
125 313
126 - return current_user_can($permission);
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, '');
127 339 }
128 340
129 341 /**
130 - * currentUserTicketsPermissionLevel method will return the permission level for a user in tickets
131 - * @return string
342 + * Get the mailbox IDs that the current agent is restricted from accessing.
343 + *
344 + * @return array Mailbox IDs the agent cannot access, or empty array if unrestricted.
132 345 */
133 - public static function currentUserTicketsPermissionLevel()
346 + public static function getRestrictedMailboxIds()
134 347 {
135 - $permissions = self::currentUserPermissions();
348 + return (new AgentTicketAccess())->getRestrictedMailboxIds();
136 349
350 + }
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 + ]);
365 + }
366 +
367 + /**
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 + *
371 + * @return bool
372 + */
373 + public static function canAccessTicketRoutes()
374 + {
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 + ]);
383 + }
384 +
385 + /**
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.
393 + */
394 + private static function resolveTicketVisibility(array $permissions)
395 + {
396 + // Manage-level permissions take priority for visibility
137 397 if (in_array('fst_manage_other_tickets', $permissions)) {
138 - return 'all';
398 + return self::VISIBILITY_ALL;
139 399 }
140 400
141 401 if (in_array('fst_manage_unassigned_tickets', $permissions)) {
142 - return 'own_plus';
402 + return self::VISIBILITY_ASSIGNED_AND_UNASSIGNED;
143 403 }
144 404
145 - return 'own';
405 + if (in_array('fst_manage_own_tickets', $permissions)) {
406 + return self::VISIBILITY_ASSIGNED_ONLY;
407 + }
408 +
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;
146 416 }
147 417
148 418 /**
149 - * 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
150 430 * @param false $userId
151 431 * @return string
152 432 */
153 - public static function agentTicketPermissionLevel($userId = false)
433 + public static function getAgentTicketVisibility($userId = false)
154 434 {
155 - if(!$userId) {
435 + if (!$userId) {
156 436 $userId = get_current_user_id();
157 437 }
158 438
159 439 $permissions = self::getUserPermissions($userId);
160 440
161 - if (in_array('fst_manage_other_tickets', $permissions)) {
162 - return 'all';
163 - }
164 -
165 - if (in_array('fst_manage_unassigned_tickets', $permissions)) {
166 - return 'own_plus';
167 - }
168 -
169 - return 'own';
441 + return self::resolveTicketVisibility($permissions);
170 442 }
171 443
172 444 /**
173 - * 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
174 446 * @param $ticket
175 447 * @return bool
176 448 */
177 - public static function hasTicketPermission($ticket)
449 + public static function canAccessTicket($ticket)
178 450 {
179 - $permissionLevel = self::currentUserTicketsPermissionLevel();
180 - if ($permissionLevel == 'all') {
181 - return true;
182 - }
183 - $agent = Helper::getAgentByUserId();
184 - if ($ticket->agent_id == $agent->id) {
185 - return true;
186 - }
187 -
188 - return !$ticket->agent_id && $permissionLevel == 'own_plus';
451 + return (new AgentTicketAccess())->currentAgentCanAccess($ticket);
189 452 }
190 453
191 454 /**
192 455 * getReadablePermissionGroups method will return the permission group as array
@@ -201,9 +464,15 @@
201 464 'fst_view_dashboard' => __('View Dashboard', 'fluent-support'),
202 465 'fst_manage_own_tickets' => __('Manage Own Tickets', 'fluent-support'),
203 466 'fst_manage_unassigned_tickets' => __('Manage Unassigned Tickets', 'fluent-support'),
204 467 'fst_manage_other_tickets' => __('Manage Others Tickets', 'fluent-support'),
205 - 'fst_delete_tickets' => __('Delete Tickets', 'fluent-support'),
468 + 'fst_assign_agents' => __('Assign Agents', 'fluent-support'),
469 + 'fst_delete_tickets' => __('Delete Tickets & Individual Responses', 'fluent-support'),
470 + 'fst_merge_tickets' => __('Merge Tickets', 'fluent-support'),
471 + 'fst_split_ticket' => __('Split Ticket', 'fluent-support'),
472 + 'fst_draft_reply' => __('Draft Reply', 'fluent-support'),
473 + 'fst_approve_draft_reply' => __('Approve Draft Reply', 'fluent-support'),
474 + 'fst_view_tickets' => __('View Tickets (Read Only)', 'fluent-support'),
206 475 ]
207 476 ],
208 477 [
209 478 'title' => __('Workflow Permissions', 'fluent-support'),
@@ -223,10 +492,71 @@
223 492 [
224 493 'title' => __('Reporting', 'fluent-support'),
225 494 'permissions' => [
226 495 'fst_view_all_reports' => __('View All Reports', 'fluent-support'),
227 - 'fst_view_activity_logs' => __('View Activity Logs', 'fluent-support')
496 + 'fst_view_activity_logs' => __('View Activity Logs', 'fluent-support'),
497 + 'fst_agent_today_performance' => __('View Agent Today Performance', 'fluent-support'),
228 498 ]
229 499 ]
230 500 ];
501 + }
502 +
503 + public static function getMailboxesForRestriction()
504 + {
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';
231 561 }
232 562 }