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