PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
← All changes | app/Modules/MCP/Support/PermissionGate.php +52 -102 2.4.0 → 2.5.0 View file →
@@ -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 */