PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / MCP / Support / PermissionGate.php

PermissionGate.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Modules/MCP/Support/PermissionGate.php

182 lines 6.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\MCP\Support;
4
5 defined('ABSPATH') || exit;
6
7 use FluentForm\App\Modules\Acl\Acl;
8 use FluentForm\App\Services\Manager\FormManagerService;
9
10 /**
11 * Maps MCP abilities onto FluentForm's existing capability model. The MCP user
12 * IS a WordPress user with a FluentForm role, so we never invent a parallel
13 * permission system — we reuse Acl, the same check the admin REST routes use.
14 *
15 * Two layers:
16 * - transport(): can this user reach the FluentForm MCP endpoint at all?
17 * - can()/canAny(): per-ability permission_callback gating, form-scoped.
18 *
19 * Annotations are UX hints only; THIS is the enforcement boundary.
20 *
21 * WHAT THE PER-ABILITY LAYER ACTUALLY ENFORCES — read before relying on it.
22 * Acl::hasPermission() grants any FluentForm permission except
23 * fluentform_full_access to a user whose WP role appears in the
24 * _fluentform_form_permission option (see Acl::findUserCapability(); that option
25 * stores role keys, and WP_User::has_cap('editor') is true for an Editor). So an
26 * Editor granted FluentForm access passes the 'capability' gate on EVERY tool —
27 * including delete-submission and bulk delete_permanently — regardless of which
28 * capability that tool declares. Only per-user managers, who receive individual
29 * caps via Acl::attachPermissions(), are gated the way the declarations read.
30 *
31 * That is long-standing Acl behaviour shared with the admin REST routes, not
32 * something this module introduces, and it is deliberately NOT worked around
33 * here: diverging from Acl would mean MCP silently refusing operations the same
34 * user can perform in the admin UI. But it means the real boundary is
35 * "authenticated WordPress user with FluentForm access, restricted to their form
36 * scope" — NOT per-tool capability separation. Do not describe it as the latter.
37 * Form scope (FormAccess) is the layer that genuinely constrains reach.
38 */
39 class PermissionGate
40 {
41 const OPTION = '_fluentform_mcp_settings';
42
43 /**
44 * Per-ability check. $formId scopes the check to a single form so a
45 * "specific forms" manager can't reach data outside their assignment
46 * (Acl::hasPermission defers form scoping to FormManagerService).
47 */
48 public static function can($permission, $formId = false)
49 {
50 return Acl::hasPermission($permission, $formId);
51 }
52
53 /** True if the user holds ANY of the given capabilities (no form scope). */
54 public static function canAny(array $permissions, $formId = false)
55 {
56 foreach ($permissions as $permission) {
57 if (Acl::hasPermission($permission, $formId)) {
58 return true;
59 }
60 }
61
62 return false;
63 }
64
65 /**
66 * Transport gate for the `fluentform` server. Reaching the endpoint at all
67 * requires (a) the feature is enabled and (b) the user holds at least one
68 * FluentForm capability. A per-ability permission_callback still runs on top,
69 * but see the class docblock: for role-granted users that callback does not
70 * separate read from write, so treat this as an access gate, not a
71 * privilege-separation one.
72 */
73 public static function transport($request = null)
74 {
75 if (!self::isEnabled()) {
76 return new \WP_Error(
77 'fluentform_mcp_disabled',
78 __('The FluentForm MCP server is disabled. Enable it in FluentForm → Settings → MCP.', 'fluentform')
79 );
80 }
81
82 if (!is_user_logged_in()) {
83 return new \WP_Error(
84 'fluentform_mcp_unauthorized',
85 __('Authentication required to access the FluentForm MCP server.', 'fluentform')
86 );
87 }
88
89 if (!Acl::hasAnyFormPermission()) {
90 return new \WP_Error(
91 'fluentform_mcp_forbidden',
92 __('Your account does not have FluentForm access.', 'fluentform')
93 );
94 }
95
96 return true;
97 }
98
99 /** Any one of these means "has at least a FluentForm role." */
100 public static function readRoleCaps()
101 {
102 return [
103 'fluentform_dashboard_access',
104 'fluentform_forms_manager',
105 'fluentform_entries_viewer',
106 'fluentform_view_payments',
107 'fluentform_settings_manager',
108 ];
109 }
110
111 /**
112 * Effective form scope for the current user: false = unrestricted,
113 * [] = restricted to no forms, [ids] = restricted to those forms. Tools use
114 * this to filter list results and to reject single-record access outside the
115 * scope (IDOR-safe — never trust a form_id param alone).
116 *
117 * @return array<int>|false
118 */
119 public static function formScope()
120 {
121 return FormManagerService::getUserAllowedFormsScope();
122 }
123
124 /** True when the current user may access the given form. */
125 public static function canAccessForm($formId)
126 {
127 return FormManagerService::hasFormPermission($formId);
128 }
129
130 /**
131 * The master on/off switch. Ships OFF; enabled from Settings → MCP. Stored in
132 * a dedicated autoloaded option so the boot guard costs no extra query.
133 */
134 public static function isEnabled()
135 {
136 $settings = get_option(self::OPTION, []);
137
138 return is_array($settings) && isset($settings['enabled']) && 'yes' === $settings['enabled'];
139 }
140
141 /**
142 * Persist the master switch. Enabling MCP opens the whole tool surface, so we
143 * fail closed unless the caller can manage_options (defense in depth — the
144 * REST route is gated too, but the toolkit toggle path delegates auth out).
145 */
146 public static function setEnabled($enabled)
147 {
148 if (!current_user_can('manage_options')) {
149 return false;
150 }
151
152 $settings = get_option(self::OPTION, []);
153 if (!is_array($settings)) {
154 $settings = [];
155 }
156
157 $settings['enabled'] = $enabled ? 'yes' : 'no';
158
159 update_option(self::OPTION, $settings, 'yes');
160
161 return (bool) $enabled;
162 }
163
164 /**
165 * Drop settings keys from removed features so stale state can't linger. Writes
166 * only when a legacy key is actually present, so it self-heals once and is a
167 * no-op thereafter. Currently prunes 'new_tools_enabled' (the retired
168 * advanced-tools opt-in). Runs unauthenticated: it removes dead data, never
169 * changes the live 'enabled' switch.
170 */
171 public static function pruneLegacyKeys()
172 {
173 $settings = get_option(self::OPTION, []);
174 if (!is_array($settings) || !array_key_exists('new_tools_enabled', $settings)) {
175 return;
176 }
177
178 unset($settings['new_tools_enabled']);
179 update_option(self::OPTION, $settings, 'yes');
180 }
181 }
182