| @@ -9,53 +9,41 @@ | ||
| 9 | 9 | |
| 10 | 10 | defined('ABSPATH') || exit; |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | - * Maps MCP abilities onto FluentBooking's existing capability model. | |
| 13 | + * Maps MCP abilities onto FluentBooking's capability model. The caller is a | |
| 14 | + * WordPress user on an application password, so every check delegates to | |
| 15 | + * PermissionManager, the same layer the admin REST policies use. | |
| 14 | 16 | * |
| 15 | - * The MCP caller IS a WordPress user authenticating with an application | |
| 16 | - * password, so there is no parallel permission system here — every check | |
| 17 | - * delegates to PermissionManager, the same layer the admin REST policies use. | |
| 18 | - * Its eight permission keys (allPermissionSets()) are the whole vocabulary. | |
| 17 | + * Three layers: | |
| 18 | + * 1. isEnabled() the master switch, off by default. MCPInit only registers | |
| 19 | + * the server when it is on. | |
| 20 | + * 2. transport() may this user reach the endpoint at all. | |
| 21 | + * 3. readGate() / bookingWriteGate() / scheduleWriteGate() per-ability | |
| 22 | + * permission_callbacks, which keep write tools out of a | |
| 23 | + * read-only account's tools/list. | |
| 19 | 24 | * |
| 20 | - * Three layers, in order: | |
| 25 | + * Layer 3 only answers "may this account use this kind of tool". Per-record | |
| 26 | + * checks (BookingWriter::canWriteBooking(), PermissionManager::canWriteCalendar()) | |
| 27 | + * run inside the tool, since the permission_callback doesn't have the record. | |
| 21 | 28 | * |
| 22 | - * 1. isEnabled() — the master switch. Ships off; MCPInit only registers the | |
| 23 | - * server when it is on, so a site that never turns it on | |
| 24 | - * pays nothing. | |
| 25 | - * 2. transport() — can this user reach the endpoint at all? Every ability's | |
| 26 | - * permission_callback starts here. | |
| 27 | - * 3. readGate() / bookingWriteGate() / scheduleWriteGate() — the per-ability | |
| 28 | - * permission_callbacks. These are what keep a write tool | |
| 29 | - * out of a read-only account's tools/list in the first | |
| 30 | - * place, rather than letting it be advertised and then | |
| 31 | - * refused at execute time. | |
| 32 | - * | |
| 33 | - * Layer 3 is a gate, not the whole check. It answers "may this account use this | |
| 34 | - * KIND of tool at all"; the per-record question ("this booking, this calendar") | |
| 35 | - * is answered inside the tool by BookingWriter::canWriteBooking(), | |
| 36 | - * PermissionManager::canWriteCalendar() and friends, because it needs the record | |
| 37 | - * and the permission_callback does not have it. | |
| 38 | - * | |
| 39 | - * MCP tool annotations (readonly / destructive) are UX hints for the client. | |
| 40 | - * THIS is the enforcement boundary. | |
| 29 | + * Tool annotations (readonly / destructive) are client hints; this is the | |
| 30 | + * enforcement boundary. | |
| 41 | 31 | */ |
| 42 | 32 | class PermissionGate |
| 43 | 33 | { |
| 44 | 34 | /** |
| 45 | - * Dedicated option rather than a key in the `_fluent_booking_enabled_modules` | |
| 46 | - * blob: SettingsController::updateGlobalModules() coerces every value in | |
| 47 | - * that blob to the scalar 'yes'/'no', so it cannot carry the toolsets array | |
| 48 | - * without changing a writer that pro and the admin UI both depend on. | |
| 49 | - * Autoloaded, so the boot-time isEnabled() check costs no extra query. | |
| 35 | + * Own option, not a key in `_fluent_booking_enabled_modules`: | |
| 36 | + * SettingsController::updateGlobalModules() coerces every value there to | |
| 37 | + * 'yes'/'no', so it can't hold the toolsets array. Autoloaded, so the | |
| 38 | + * boot-time isEnabled() check costs no query. | |
| 50 | 39 | */ |
| 51 | 40 | const OPTION_KEY = '_fluent_booking_mcp_settings'; |
| 52 | 41 | |
| 53 | 42 | /** |
| 54 | - * Toolsets, and which ship on. See docs/mcp-server-spec.md §3.2 — every tool | |
| 55 | - * definition stays resident in the client's context for the whole session | |
| 56 | - * (~500 tokens each, measured), so exposure is a setting rather than a fixed | |
| 57 | - * decision. `core` is not switchable: a server with no tools is not a server. | |
| 43 | + * Toolsets are a setting because every tool definition stays in the | |
| 44 | + * client's context all session (~500 tokens each; docs/mcp-server-spec.md | |
| 45 | + * §3.2). `core` can't be switched off. | |
| 58 | 46 | */ |
| 59 | 47 | const TOOLSET_CORE = 'core'; |
| 60 | 48 | |
| 61 | 49 | const TOOLSET_SCHEDULING = 'scheduling'; |
| @@ -62,12 +50,11 @@ | ||
| 62 | 50 | |
| 63 | 51 | const TOOLSET_PAYMENTS = 'payments'; |
| 64 | 52 | |
| 65 | 53 | /** |
| 66 | - * Permission sets that may change bookings. `manage_own_calendar` is here | |
| 67 | - * because it is the base host grant: a host can always act on their own | |
| 68 | - * bookings, and the per-record check inside the tool is what stops them | |
| 69 | - * acting on anybody else's. | |
| 54 | + * Permission sets that may change bookings. `manage_own_calendar` is the | |
| 55 | + * base host grant; the per-record check in the tool keeps a host to their | |
| 56 | + * own bookings. | |
| 70 | 57 | */ |
| 71 | 58 | const BOOKING_WRITE_CAPS = [ |
| 72 | 59 | 'manage_own_calendar', |
| 73 | 60 | 'manage_all_bookings', |
| @@ -74,10 +61,9 @@ | ||
| 74 | 61 | 'manage_all_data', |
| 75 | 62 | ]; |
| 76 | 63 | |
| 77 | 64 | /** |
| 78 | - * Permission sets that may change scheduling configuration — event types | |
| 79 | - * and availability schedules. | |
| 65 | + * Permission sets that may change event types and availability schedules. | |
| 80 | 66 | */ |
| 81 | 67 | const SCHEDULE_WRITE_CAPS = [ |
| 82 | 68 | 'manage_own_calendar', |
| 83 | 69 | 'manage_other_calendars', |
| @@ -85,12 +71,10 @@ | ||
| 85 | 71 | 'manage_all_data', |
| 86 | 72 | ]; |
| 87 | 73 | |
| 88 | 74 | /** |
| 89 | - * permission_callback for every read-only ability: reaching the endpoint is | |
| 90 | - * the whole bar, because holding any FluentBooking permission implies being | |
| 91 | - * allowed to see *something*, and each tool scopes its own query to | |
| 92 | - * whatever that something is. | |
| 75 | + * permission_callback for read-only abilities. Reaching the endpoint is | |
| 76 | + * enough; each tool scopes its own query to what the caller may see. | |
| 93 | 77 | * |
| 94 | 78 | * @param mixed $request |
| 95 | 79 | * @return true|\WP_Error |
| 96 | 80 | */ |
| @@ -142,21 +126,11 @@ | ||
| 142 | 126 | } |
| 143 | 127 | |
| 144 | 128 | /** |
| 145 | 129 | * Calendar ids this caller may read, or false when they may read all of |
| 146 | - * them. | |
| 130 | + * them. Every tool uses this so lists and detail reads agree on scope. | |
| 131 | + * Cached per request. | |
| 147 | 132 | * |
| 148 | - * The one answer to "which calendars can this account see", so the context | |
| 149 | - * payload, the event-type list, the reference lists and the availability | |
| 150 | - * tools cannot drift into showing each other's users different sites. Before | |
| 151 | - * this there were three spellings of the question — `user_id = me`, | |
| 152 | - * `hasAllCalendarAccess()` and `canReadCalendar()` — and the last is | |
| 153 | - * strictly the widest, so a list built on the first would hide an event type | |
| 154 | - * that the detail read would happily return. | |
| 155 | - * | |
| 156 | - * Resolved once per request: it walks every calendar, and the tools that | |
| 157 | - * need it call it several times. | |
| 158 | - * | |
| 159 | 133 | * @return array|false false means "no restriction" |
| 160 | 134 | */ |
| 161 | 135 | public static function readableCalendarIds() |
| 162 | 136 | { |
| @@ -163,15 +137,11 @@ | ||
| 163 | 137 | static $cache = []; |
| 164 | 138 | |
| 165 | 139 | $userId = get_current_user_id(); |
| 166 | 140 | |
| 167 | - // Keyed by the permission SET, not just the user id. A user's grants can | |
| 168 | - // change inside one request — the permission-matrix gate does exactly | |
| 169 | - // that, granting one set at a time to a single probe account — and a | |
| 170 | - // cache keyed on the id alone would answer every later set with the | |
| 171 | - // first set's calendars. | |
| 172 | - // Blog id included as well: a request that switches site mid-flight on | |
| 173 | - // multisite would otherwise reuse the first site's calendar ids. | |
| 141 | + // Keyed by permission set and blog too: grants can change within one | |
| 142 | + // request (the permission-matrix gate does this), and a multisite | |
| 143 | + // request can switch blogs. | |
| 174 | 144 | $blogId = function_exists('get_current_blog_id') ? get_current_blog_id() : 0; |
| 175 | 145 | |
| 176 | 146 | $key = $blogId . '|' . $userId . '|' . md5((string) wp_json_encode(PermissionManager::getUserPermissions())); |
| 177 | 147 | |
| @@ -187,19 +157,14 @@ | ||
| 187 | 157 | } |
| 188 | 158 | |
| 189 | 159 | /** |
| 190 | 160 | * The calendars a restricted user may read: the ones they own, plus the |
| 191 | - * ones CalendarService::isSharedCalendar() would admit them to. | |
| 161 | + * ones CalendarService::isSharedCalendar() would admit them to. Three | |
| 162 | + * narrow queries instead of hydrating every calendar with its events. | |
| 192 | 163 | * |
| 193 | - * Three narrow reads rather than hydrating every Calendar with its events. | |
| 194 | - * The old loop pulled the site's whole calendar and event set into PHP to | |
| 195 | - * produce a handful of ids, on every MCP request, because each tool call is | |
| 196 | - * its own request. | |
| 164 | + * `settings` is PHP-serialized, so team_members can't be filtered in SQL. | |
| 165 | + * The LIKE only narrows the rows; the in_array below decides. | |
| 197 | 166 | * |
| 198 | - * team_members cannot be filtered in SQL: `settings` is PHP-serialized, not | |
| 199 | - * JSON, so JSON_EXTRACT errors on it. The LIKE narrows the rows worth | |
| 200 | - * unserializing; the in_array below is what decides. | |
| 201 | - * | |
| 202 | 167 | * @param int $userId |
| 203 | 168 | * |
| 204 | 169 | * @return array |
| 205 | 170 | */ |
| @@ -268,12 +233,10 @@ | ||
| 268 | 233 | return $query->whereIn($column, $ids ? $ids : [0]); |
| 269 | 234 | } |
| 270 | 235 | |
| 271 | 236 | /** |
| 272 | - * True when the caller may read bookings beyond their own calendars. | |
| 273 | - * Wrapped rather than inlined because list + report tools all branch on it | |
| 274 | - * and must branch identically — a scope check that drifts between two tools | |
| 275 | - * is a data leak, not a style issue. | |
| 237 | + * True when the caller may read bookings beyond their own calendars. One | |
| 238 | + * helper so list and report tools can't drift apart on scope. | |
| 276 | 239 | * |
| 277 | 240 | * @return bool |
| 278 | 241 | */ |
| 279 | 242 | public static function canSeeAllBookings() |
| @@ -281,10 +244,9 @@ | ||
| 281 | 244 | return PermissionManager::userCanSeeAllBookings(); |
| 282 | 245 | } |
| 283 | 246 | |
| 284 | 247 | /** |
| 285 | - * The scope marker for `meta.scope`, derived from the same check the query | |
| 286 | - * uses so the two can never disagree. | |
| 248 | + * The `meta.scope` marker, from the same check the query uses. | |
| 287 | 249 | * |
| 288 | 250 | * @return string |
| 289 | 251 | */ |
| 290 | 252 | public static function currentScope() |
| @@ -292,17 +254,12 @@ | ||
| 292 | 254 | return self::canSeeAllBookings() ? MCPHelper::SCOPE_ALL : MCPHelper::SCOPE_OWN; |
| 293 | 255 | } |
| 294 | 256 | |
| 295 | 257 | /** |
| 296 | - * Transport gate for the `fluent-booking` server: may this request reach the | |
| 297 | - * endpoint at all? | |
| 258 | + * Transport gate for the `fluent-booking` server. Replaces the adapter's | |
| 259 | + * default `current_user_can('read')`, which every subscriber passes and is | |
| 260 | + * too loose for attendee contact data. Per-ability checks still run on top. | |
| 298 | 261 | * |
| 299 | - * The adapter's default gate is `current_user_can('read')`, which every | |
| 300 | - * subscriber on the site passes — far too loose for a surface that returns | |
| 301 | - * attendee names, emails and phone numbers. Per-ability permission_callbacks | |
| 302 | - * still run on top; a host who gets through here still cannot cancel someone | |
| 303 | - * else's booking. | |
| 304 | - * | |
| 305 | 262 | * @param mixed $request unused; the adapter passes the REST request |
| 306 | 263 | * @return true|\WP_Error |
| 307 | 264 | */ |
| 308 | 265 | public static function transport($request = null) |
| @@ -341,10 +298,9 @@ | ||
| 341 | 298 | { |
| 342 | 299 | static $settings = null; |
| 343 | 300 | static $forBlog = null; |
| 344 | 301 | |
| 345 | - // Keyed by blog: a mid-request site switch on multisite would otherwise | |
| 346 | - // hand the second site the first site's toolset selection. | |
| 302 | + // Keyed by blog, in case a multisite request switches sites. | |
| 347 | 303 | $blogId = function_exists('get_current_blog_id') ? get_current_blog_id() : 0; |
| 348 | 304 | |
| 349 | 305 | if ($cached && $settings !== null && $forBlog === $blogId) { |
| 350 | 306 | return $settings; |
| @@ -366,9 +322,9 @@ | ||
| 366 | 322 | return $settings; |
| 367 | 323 | } |
| 368 | 324 | |
| 369 | 325 | /** |
| 370 | - * The master switch. Ships off. | |
| 326 | + * The master switch. Off by default. | |
| 371 | 327 | * |
| 372 | 328 | * @return bool |
| 373 | 329 | */ |
| 374 | 330 | public static function isEnabled() |
| @@ -380,14 +336,11 @@ | ||
| 380 | 336 | |
| 381 | 337 | /** |
| 382 | 338 | * Persist the master switch. |
| 383 | 339 | * |
| 384 | - * Enabling MCP opens the whole tool surface, so the capability is | |
| 385 | - * re-checked here even though every caller is already behind | |
| 386 | - * SettingsPolicy: the FluentToolkit toggle path delegates authorization to | |
| 387 | - * an external plugin, and defence in depth at the write is cheaper than | |
| 388 | - * trusting that. `manage_options` (not is_super_admin) is correct — this is | |
| 389 | - * a per-site plugin setting stored in a per-site option. | |
| 340 | + * The capability is re-checked here even though callers sit behind | |
| 341 | + * SettingsPolicy, because the FluentToolkit toggle path delegates auth to | |
| 342 | + * another plugin. `manage_options`, not is_super_admin: it's a per-site option. | |
| 390 | 343 | * |
| 391 | 344 | * @param bool $enabled |
| 392 | 345 | * @return bool the persisted state |
| 393 | 346 | */ |
| @@ -400,10 +353,9 @@ | ||
| 400 | 353 | return self::saveSettings(['enabled' => $enabled ? 'yes' : 'no']); |
| 401 | 354 | } |
| 402 | 355 | |
| 403 | 356 | /** |
| 404 | - * Toolsets currently exposed. `core` is always present even if a stored | |
| 405 | - * value somehow omits it. | |
| 357 | + * Toolsets currently exposed. Always includes `core`. | |
| 406 | 358 | * |
| 407 | 359 | * @return array |
| 408 | 360 | */ |
| 409 | 361 | public static function enabledToolsets() |
| @@ -439,11 +391,10 @@ | ||
| 439 | 391 | return self::enabledToolsets(); |
| 440 | 392 | } |
| 441 | 393 | |
| 442 | 394 | /** |
| 443 | - * Every toolset the server knows about, with its label. `payments` is | |
| 444 | - * advertised only when Pro is active — offering a switch that cannot do | |
| 445 | - * anything is worse than not offering it. | |
| 395 | + * Every toolset the server knows about, with its label. `payments` is only | |
| 396 | + * offered when Pro is active. | |
| 446 | 397 | * |
| 447 | 398 | * @return array keyed by toolset slug |
| 448 | 399 | */ |
| 449 | 400 | public static function availableToolsets() |
| @@ -472,10 +423,9 @@ | ||
| 472 | 423 | return $toolsets; |
| 473 | 424 | } |
| 474 | 425 | |
| 475 | 426 | /** |
| 476 | - * Coerce a stored / submitted toolset list to known slugs, always including | |
| 477 | - * `core`. | |
| 427 | + * Coerce a toolset list to known slugs, always including `core`. | |
| 478 | 428 | * |
| 479 | 429 | * @param mixed $toolsets |
| 480 | 430 | * @return array |
| 481 | 431 | */ |