PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
fluent-boards / app / Services / MCPAccessService.php

MCPAccessService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.0, at app/Services/MCPAccessService.php

394 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\App\Modules\MCP\MCPInit;
6
7 /**
8 * Who may use MCP, and the application passwords created for it.
9 *
10 * Admins may always use MCP while it is enabled; board members may use it
11 * when the `mcp_member_access` setting is on. Credential ownership is stored
12 * as a site-scoped user option, so a multisite network keeps it separate
13 * per site. Feature-created
14 * application passwords carry APP_ID and may only authenticate the Fluent
15 * Boards MCP endpoint of the site that created them.
16 */
17 class MCPAccessService
18 {
19 const CREDENTIALS_OPTION = 'fbs_mcp_credentials';
20 const APP_ID = '6b1f0c52-7d4e-4f0a-9a43-fb5c0b4d2e71';
21
22 /** Password item that authenticated the current request, captured before any later deletion. */
23 private static $authenticatedItem = null;
24
25 /** Per-request canUseMcp() results, keyed by user id. */
26 private static $canUseCache = [];
27
28 /**
29 * Wire MCP enforcement. Runs even when MCP is globally disabled so
30 * feature-owned credentials stay restricted.
31 */
32 public static function registerHooks()
33 {
34 add_filter('wp_register_ability_args', [self::class, 'gateAbilityArgs'], 10, 2);
35 add_filter('rest_pre_dispatch', [self::class, 'gateRestRequest'], 10, 3);
36 add_action('wp_authenticate_application_password_errors', [self::class, 'blockXmlRpcLogin'], 10, 3);
37 add_action('application_password_did_authenticate', [self::class, 'captureAuthenticatedItem'], 10, 2);
38 }
39
40 public static function isGloballyEnabled()
41 {
42 return fluent_boards_get_option('mcp_enabled', 'yes') === 'yes';
43 }
44
45 /** Whether board members (not only admins) may use MCP. Off by default. */
46 public static function isMemberAccessEnabled()
47 {
48 return fluent_boards_get_option('mcp_member_access', 'no') === 'yes';
49 }
50
51 /**
52 * Admins are always allowed; board members only when member access is on.
53 */
54 public static function isApproved($userId)
55 {
56 $userId = (int) $userId;
57 if (!$userId) {
58 return false;
59 }
60
61 if (PermissionManager::isAdmin($userId, false)) {
62 return true;
63 }
64
65 return self::isMemberAccessEnabled() && PermissionManager::isFluentBoardsUser($userId);
66 }
67
68 public static function canUseMcp($userId = null)
69 {
70 $userId = $userId ? (int) $userId : get_current_user_id();
71
72 // Endpoint gate and each ability's permission check ask the same question within one request.
73 if (!array_key_exists($userId, self::$canUseCache)) {
74 self::$canUseCache[$userId] = $userId > 0
75 && self::isGloballyEnabled()
76 && PermissionManager::hasAppAccess($userId)
77 && self::isApproved($userId);
78 }
79
80 return self::$canUseCache[$userId];
81 }
82
83 /**
84 * State the profile panel needs to decide whether creation is possible.
85 */
86 public static function getProfileState($userId)
87 {
88 $user = get_userdata($userId);
89 $appPasswordsAvailable = $user
90 && class_exists('\WP_Application_Passwords')
91 && wp_is_application_passwords_available_for_user($user)
92 && user_can($user, 'create_app_password', $userId);
93
94 return [
95 'enabled' => self::isGloballyEnabled(),
96 'adapter_available' => function_exists('wp_register_ability') && did_action('mcp_adapter_init') > 0,
97 'network_enforced' => self::isNetworkEnforced(),
98 'approved' => self::isApproved($userId),
99 'member_access' => self::isMemberAccessEnabled(),
100 'is_local_dev' => self::isLocalDev(),
101 'app_passwords_available' => (bool) $appPasswordsAvailable,
102 'endpoint' => MCPInit::getEndpointUrl(),
103 'username' => $user ? $user->user_login : '',
104 'connections' => self::listConnections($userId),
105 ];
106 }
107
108 /**
109 * Feature-owned connections without password hashes.
110 */
111 public static function listConnections($userId)
112 {
113 if (!class_exists('\WP_Application_Passwords')) {
114 return [];
115 }
116
117 $owned = self::getOwnedUuids($userId);
118 $connections = [];
119
120 foreach (\WP_Application_Passwords::get_user_application_passwords($userId) as $item) {
121 if (!in_array($item['uuid'], $owned, true)) {
122 continue;
123 }
124
125 $connections[] = [
126 'uuid' => $item['uuid'],
127 'name' => $item['name'],
128 'created' => $item['created'] ? gmdate('Y-m-d H:i:s', $item['created']) : null,
129 'last_used' => $item['last_used'] ? gmdate('Y-m-d H:i:s', $item['last_used']) : null,
130 ];
131 }
132
133 return $connections;
134 }
135
136 /**
137 * Create a connection for the user. The plaintext password is returned once.
138 *
139 * @return array|\WP_Error
140 */
141 public static function createConnection($userId, $name)
142 {
143 $state = self::getProfileState($userId);
144
145 if (!$state['enabled']) {
146 return new \WP_Error('mcp_disabled', __('MCP is disabled on this site.', 'fluent-boards'), ['status' => 403]);
147 }
148 if (!$state['approved']) {
149 return new \WP_Error('mcp_not_approved', __('MCP is only available to administrators on this site.', 'fluent-boards'), ['status' => 403]);
150 }
151 if (!$state['adapter_available']) {
152 return new \WP_Error('mcp_adapter_unavailable', __('The MCP adapter is not active on this site.', 'fluent-boards'), ['status' => 403]);
153 }
154 if (!$state['network_enforced']) {
155 return new \WP_Error('mcp_network_scope', __('On multisite, Fluent Boards must be network-activated before MCP keys can be created.', 'fluent-boards'), ['status' => 403]);
156 }
157 if (!$state['app_passwords_available']) {
158 return new \WP_Error('app_passwords_unavailable', __('Application passwords are not available for your account.', 'fluent-boards'), ['status' => 403]);
159 }
160
161 $name = sanitize_text_field($name);
162 if ($name === '') {
163 return new \WP_Error('invalid_name', __('Connection name is required.', 'fluent-boards'), ['status' => 422]);
164 }
165
166 $created = self::withUserLock($userId, function () use ($userId, $name) {
167 $created = \WP_Application_Passwords::create_new_application_password($userId, [
168 'name' => $name,
169 'app_id' => self::APP_ID,
170 ]);
171
172 if (is_wp_error($created)) {
173 return $created;
174 }
175
176 $uuid = $created[1]['uuid'];
177 $owned = self::getOwnedUuids($userId);
178 $owned[] = $uuid;
179 self::saveOwnedUuids($userId, array_values(array_unique($owned)));
180
181 if (!in_array($uuid, self::getOwnedUuids($userId), true)) {
182 \WP_Application_Passwords::delete_application_password($userId, $uuid);
183 return new \WP_Error('credential_not_saved', __('Could not save the connection. Please try again.', 'fluent-boards'), ['status' => 500]);
184 }
185
186 // A denial may have landed while the key was being created; never leave it usable.
187 if (!self::isApproved($userId)) {
188 self::deleteOwned($userId, $uuid);
189 return new \WP_Error('mcp_not_approved', __('MCP is only available to administrators on this site.', 'fluent-boards'), ['status' => 403]);
190 }
191
192 return $created;
193 });
194
195 if (is_wp_error($created)) {
196 return $created;
197 }
198
199 list($password, $item) = $created;
200
201 return [
202 'password' => \WP_Application_Passwords::chunk_password($password),
203 'username' => $state['username'],
204 'endpoint' => $state['endpoint'],
205 'connection' => [
206 'uuid' => $item['uuid'],
207 'name' => $item['name'],
208 'created' => gmdate('Y-m-d H:i:s', $item['created']),
209 'last_used' => null,
210 ],
211 ];
212 }
213
214 /**
215 * Delete one feature-owned connection. Unrelated passwords are never touched.
216 *
217 * @return true|\WP_Error
218 */
219 public static function revokeConnection($userId, $uuid)
220 {
221 return self::withUserLock($userId, function () use ($userId, $uuid) {
222 return self::deleteOwned($userId, $uuid);
223 });
224 }
225
226 private static function deleteOwned($userId, $uuid)
227 {
228 $owned = self::getOwnedUuids($userId);
229 if (!in_array($uuid, $owned, true)) {
230 return new \WP_Error('not_found', __('Connection not found.', 'fluent-boards'), ['status' => 404]);
231 }
232
233 $deleted = \WP_Application_Passwords::delete_application_password($userId, $uuid);
234 if (is_wp_error($deleted) && $deleted->get_error_code() !== 'application_password_not_found') {
235 return $deleted;
236 }
237
238 self::saveOwnedUuids($userId, array_values(array_diff(self::getOwnedUuids($userId), [$uuid])));
239
240 return true;
241 }
242
243 /**
244 * Wrap every fluent-boards/* ability permission check, including extension abilities.
245 */
246 public static function gateAbilityArgs($args, $name)
247 {
248 if (strpos((string) $name, 'fluent-boards/') !== 0 || empty($args['permission_callback'])) {
249 return $args;
250 }
251
252 $callback = $args['permission_callback'];
253 $args['permission_callback'] = function (...$params) use ($callback) {
254 if (!self::canUseMcp()) {
255 return self::deniedError();
256 }
257
258 return call_user_func_array($callback, $params);
259 };
260
261 return $args;
262 }
263
264 /**
265 * Gate the MCP transport and confine feature-owned credentials to it.
266 */
267 public static function gateRestRequest($result, $server, $request)
268 {
269 if ($result !== null) {
270 return $result;
271 }
272
273 $isMcpRoute = self::isMcpRoute($request->get_route());
274
275 $item = self::$authenticatedItem;
276 if ($item && isset($item['app_id']) && $item['app_id'] === self::APP_ID) {
277 // Fails closed if the credential was revoked after authentication.
278 $ownedHere = in_array($item['uuid'], self::getOwnedUuids(get_current_user_id()), true)
279 && \WP_Application_Passwords::get_user_application_password(get_current_user_id(), $item['uuid']);
280 if (!$isMcpRoute || !$ownedHere) {
281 return new \WP_Error('fluent_boards_mcp_credential_scope', __('This credential can only be used with the Fluent Boards MCP endpoint.', 'fluent-boards'), ['status' => 403]);
282 }
283 }
284
285 if ($isMcpRoute && !self::canUseMcp()) {
286 return self::deniedError();
287 }
288
289 return $result;
290 }
291
292 public static function captureAuthenticatedItem($user, $item)
293 {
294 self::$authenticatedItem = is_array($item) ? $item : null;
295 }
296
297 /**
298 * Feature credentials never authenticate XML-RPC.
299 */
300 public static function blockXmlRpcLogin($error, $user, $item)
301 {
302 if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST && isset($item['app_id']) && $item['app_id'] === self::APP_ID) {
303 $error->add('fluent_boards_mcp_credential_scope', __('This credential can only be used with the Fluent Boards MCP endpoint.', 'fluent-boards'));
304 }
305 }
306
307 private static function isMcpRoute($route)
308 {
309 $namespace = apply_filters('fluent_boards/mcp_server_namespace', 'fluent-boards');
310 $mcpRoute = '/' . trim($namespace, '/') . '/' . trim(apply_filters('fluent_boards/mcp_server_route', 'mcp'), '/');
311 // WordPress matches REST routes case-insensitively.
312 $mcpRoute = strtolower($mcpRoute);
313 $route = strtolower(untrailingslashit((string) $route));
314
315 return $route === $mcpRoute || strpos($route, $mcpRoute . '/') === 0;
316 }
317
318 /**
319 * Feature keys are network-wide WordPress credentials, so on multisite
320 * the scope gate must run on every site.
321 */
322 private static function isNetworkEnforced()
323 {
324 if (!is_multisite()) {
325 return true;
326 }
327
328 if (!function_exists('is_plugin_active_for_network')) {
329 require_once ABSPATH . 'wp-admin/includes/plugin.php';
330 }
331
332 return is_plugin_active_for_network(plugin_basename(FLUENT_BOARDS_PLUGIN_PATH . 'fluent-boards.php'));
333 }
334
335 /**
336 * Serialize credential writes per user; WordPress rewrites the whole password array.
337 */
338 private static function withUserLock($userId, callable $callback)
339 {
340 global $wpdb;
341
342 $lock = 'fbs_mcp_keys_' . (int) $userId; // Password array is network-wide.
343 if (!$wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 10)', $lock))) {
344 return new \WP_Error('locked', __('Another MCP key change is in progress. Please try again.', 'fluent-boards'), ['status' => 409]);
345 }
346
347 try {
348 wp_cache_delete($userId, 'user_meta');
349 return $callback();
350 } finally {
351 $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
352 }
353 }
354
355 private static function saveOwnedUuids($userId, array $uuids)
356 {
357 update_user_option($userId, self::CREDENTIALS_OPTION, $uuids);
358 wp_cache_delete($userId, 'user_meta');
359 }
360
361 private static function getOwnedUuids($userId)
362 {
363 $owned = get_user_option(self::CREDENTIALS_OPTION, $userId);
364
365 return is_array($owned) ? array_values(array_filter($owned, 'is_string')) : [];
366 }
367
368 private static function deniedError()
369 {
370 return new \WP_Error('fluent_boards_mcp_access_denied', __('MCP is only available to administrators on this site.', 'fluent-boards'), ['status' => 403]);
371 }
372
373 /**
374 * Local/dev hosts usually use self-signed SSL, which some MCP bridges reject.
375 */
376 public static function isLocalDev()
377 {
378 $host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST));
379
380 $isDev = in_array($host, ['localhost', '127.0.0.1', '::1'], true);
381 foreach (['.test', '.lab', '.local', '.localhost', '.docker', '.dev'] as $tld) {
382 if (substr($host, -strlen($tld)) === $tld) {
383 $isDev = true;
384 }
385 }
386
387 if (!$isDev && filter_var($host, FILTER_VALIDATE_IP)) {
388 $isDev = !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
389 }
390
391 return (bool) apply_filters('fluent_boards/mcp_is_local_dev', $isDev, $host);
392 }
393 }
394