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 +433 -53 1.4.62.4.0 View file →
@@ -1,20 +1,43 @@
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
10 +/**
11 + * PermissionManager class is responsible for getting/settings data related to permission
12 + * @package FluentSupport\App\Modules
13 + *
14 + * @version 1.0.0
15 + */
16 +
7 17 class PermissionManager
8 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 +
26 + /**
27 + * pluginPermissions method will return the list of permissions support by Fluent Support Plugin
28 + * @return string[]
29 + */
9 30 public static function pluginPermissions()
10 31 {
11 32 return [
12 33 'fst_view_dashboard',
34 + 'fst_view_tickets',
13 35 'fst_manage_own_tickets',
14 36 'fst_manage_unassigned_tickets',
15 37 'fst_manage_other_tickets',
16 38 'fst_delete_tickets',
39 + 'fst_assign_agents',
17 40 'fst_manage_settings',
18 41 'fst_sensitive_data',
19 42 'fst_manage_workflows',
20 43 'fst_run_workflows',
@@ -19,12 +42,70 @@
19 42 'fst_manage_workflows',
20 43 'fst_run_workflows',
21 44 'fst_view_all_reports',
22 45 'fst_manage_saved_replies',
23 - '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'
24 52 ];
25 53 }
26 54
55 + /**
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 + *
104 + * @param $user
105 + * @param $permissions
106 + * @return false|mixed
107 + */
27 108 public static function attachPermissions($user, $permissions)
28 109 {
29 110 if (is_numeric($user)) {
30 111 $user = get_user_by('ID', $user);
@@ -38,21 +119,127 @@
38 119 return $user;
39 120 }
40 121
41 122 $allPermissions = self::pluginPermissions();
42 - foreach ($allPermissions as $permission) {
43 - $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';
44 145 }
45 146
46 - $permissions = array_intersect($allPermissions, $permissions);
147 + // Store permissions in user meta
148 + update_user_meta($user->ID, self::META_KEY, array_values($permissions));
47 149
48 - foreach ($permissions as $permission) {
49 - $user->add_cap($permission);
150 + // Clean up legacy WordPress capabilities
151 + foreach ($allPermissions as $cap) {
152 + $user->remove_cap($cap);
50 153 }
51 154
52 155 return $user;
53 156 }
54 157
158 + /**
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 + *
239 + * @param false $user
240 + * @return array|string[]
241 + */
55 242 public static function getUserPermissions($user = false)
56 243 {
57 244 if (is_numeric($user)) {
58 245 $user = get_user_by('ID', $user);
@@ -65,14 +252,40 @@
65 252 $pluginPermission = self::pluginPermissions();
66 253
67 254 if ($user->has_cap('manage_options')) {
68 255 $pluginPermission[] = 'administrator';
256 + $pluginPermission = array_values(array_diff($pluginPermission, ['fst_draft_reply']));
69 257 return $pluginPermission;
70 258 }
71 259
72 - 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;
73 281 }
74 282
283 + /**
284 + * currentUserPermissions method will return the permission of logged-in user
285 + * @param bool $cached
286 + * @return array|mixed|string[]
287 + */
75 288 public static function currentUserPermissions($cached = true)
76 289 {
77 290 static $permissions;
78 291
@@ -84,99 +297,266 @@
84 297
85 298 return $permissions;
86 299 }
87 300
88 - public static function currentUserCan($permission)
301 + /**
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
307 + */
308 + public static function getMenuPermission()
89 309 {
90 310 if (current_user_can('manage_options')) {
91 - return true;
311 + return 'manage_options';
92 312 }
93 313
94 - 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, '');
95 339 }
96 340
97 - public static function currentUserTicketsPermissionLevel()
341 + /**
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.
345 + */
346 + public static function getRestrictedMailboxIds()
98 347 {
99 - $permissions = self::currentUserPermissions();
348 + return (new AgentTicketAccess())->getRestrictedMailboxIds();
100 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
101 397 if (in_array('fst_manage_other_tickets', $permissions)) {
102 - return 'all';
398 + return self::VISIBILITY_ALL;
103 399 }
104 400
105 401 if (in_array('fst_manage_unassigned_tickets', $permissions)) {
106 - return 'own_plus';
402 + return self::VISIBILITY_ASSIGNED_AND_UNASSIGNED;
107 403 }
108 404
109 - 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;
110 416 }
111 417
112 - public static function agentTicketPermissionLevel($userId = false)
418 + /**
419 + * currentTicketVisibility method will return the permission level for a user in tickets
420 + * @return string
421 + */
422 + public static function currentTicketVisibility()
113 423 {
114 - if(!$userId) {
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
430 + * @param false $userId
431 + * @return string
432 + */
433 + public static function getAgentTicketVisibility($userId = false)
434 + {
435 + if (!$userId) {
115 436 $userId = get_current_user_id();
116 437 }
117 438
118 439 $permissions = self::getUserPermissions($userId);
119 440
120 - if (in_array('fst_manage_other_tickets', $permissions)) {
121 - return 'all';
122 - }
123 -
124 - if (in_array('fst_manage_unassigned_tickets', $permissions)) {
125 - return 'own_plus';
126 - }
127 -
128 - return 'own';
441 + return self::resolveTicketVisibility($permissions);
129 442 }
130 443
131 - public static function hasTicketPermission($ticket)
444 + /**
445 + * canAccessTicket method will return whether the selected user has permission in selected ticket or not
446 + * @param $ticket
447 + * @return bool
448 + */
449 + public static function canAccessTicket($ticket)
132 450 {
133 - $permissionLevel = self::currentUserTicketsPermissionLevel();
134 - if ($permissionLevel == 'all') {
135 - return true;
136 - }
137 - $agent = Helper::getAgentByUserId();
138 - if ($ticket->agent_id == $agent->id) {
139 - return true;
140 - }
141 -
142 - return !$ticket->agent_id && $permissionLevel == 'own_plus';
451 + return (new AgentTicketAccess())->currentAgentCanAccess($ticket);
143 452 }
144 453
454 + /**
455 + * getReadablePermissionGroups method will return the permission group as array
456 + * @return array[]
457 + */
145 458 public static function getReadablePermissionGroups()
146 459 {
147 460 return [
148 461 [
149 - 'title' => 'Tickets Permissions',
462 + 'title' => __('Tickets Permissions', 'fluent-support'),
150 463 'permissions' => [
151 - 'fst_view_dashboard' => 'View Dashboard',
152 - 'fst_manage_own_tickets' => 'Manage Own Tickets',
153 - 'fst_manage_unassigned_tickets' => 'Manage Unassigned Tickets',
154 - 'fst_manage_other_tickets' => 'Manage Others Tickets',
155 - 'fst_delete_tickets' => 'Delete Tickets',
464 + 'fst_view_dashboard' => __('View Dashboard', 'fluent-support'),
465 + 'fst_manage_own_tickets' => __('Manage Own Tickets', 'fluent-support'),
466 + 'fst_manage_unassigned_tickets' => __('Manage Unassigned Tickets', 'fluent-support'),
467 + 'fst_manage_other_tickets' => __('Manage Others 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'),
156 475 ]
157 476 ],
158 477 [
159 - 'title' => 'Workflow Permissions',
478 + 'title' => __('Workflow Permissions', 'fluent-support'),
160 479 'permissions' => [
161 - 'fst_manage_workflows' => 'Manage Workflows',
162 - 'fst_run_workflows' => 'Run workflows',
163 - 'fst_manage_saved_replies' => 'Manage Saved Replies'
480 + 'fst_manage_workflows' => __('Manage Workflows', 'fluent-support'),
481 + 'fst_run_workflows' => __('Run workflows', 'fluent-support'),
482 + 'fst_manage_saved_replies' => __('Manage Saved Replies', 'fluent-support')
164 483 ]
165 484 ],
166 485 [
167 - 'title' => 'Settings',
486 + 'title' => __('Settings', 'fluent-support'),
168 487 'permissions' => [
169 - 'fst_manage_settings' => 'Manage Overall Settings',
170 - 'fst_sensitive_data' => 'Access Private Data (Customers, Agents)'
488 + 'fst_manage_settings' => __('Manage Overall Settings', 'fluent-support'),
489 + 'fst_sensitive_data' => __('Access Private Data (Customers, Agents)', 'fluent-support')
171 490 ]
172 491 ],
173 492 [
174 - 'title' => 'Reporting',
493 + 'title' => __('Reporting', 'fluent-support'),
175 494 'permissions' => [
176 - 'fst_view_all_reports' => 'View All Reports',
177 - 'fst_view_activity_logs' => 'View Activity Logs'
495 + 'fst_view_all_reports' => __('View All Reports', 'fluent-support'),
496 + 'fst_view_activity_logs' => __('View Activity Logs', 'fluent-support'),
497 + 'fst_agent_today_performance' => __('View Agent Today Performance', 'fluent-support'),
178 498 ]
179 499 ]
180 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';
181 561 }
182 562 }