| 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 |
|