PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.4.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.4.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
fluent-booking / app / Modules / MCP / Support / PermissionGate.php

PermissionGate.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 2.4.0, at app/Modules/MCP/Support/PermissionGate.php

517 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Support;
4
5 use FluentBooking\App\Models\Calendar;
6 use FluentBooking\App\Models\CalendarSlot;
7 use FluentBooking\App\Services\PermissionManager;
8 use FluentBooking\Framework\Support\Arr;
9
10 defined('ABSPATH') || exit;
11
12 /**
13 * Maps MCP abilities onto FluentBooking's existing capability model.
14 *
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.
19 *
20 * Three layers, in order:
21 *
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.
41 */
42 class PermissionGate
43 {
44 /**
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.
50 */
51 const OPTION_KEY = '_fluent_booking_mcp_settings';
52
53 /**
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.
58 */
59 const TOOLSET_CORE = 'core';
60
61 const TOOLSET_SCHEDULING = 'scheduling';
62
63 const TOOLSET_PAYMENTS = 'payments';
64
65 /**
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.
70 */
71 const BOOKING_WRITE_CAPS = [
72 'manage_own_calendar',
73 'manage_all_bookings',
74 'manage_all_data',
75 ];
76
77 /**
78 * Permission sets that may change scheduling configuration — event types
79 * and availability schedules.
80 */
81 const SCHEDULE_WRITE_CAPS = [
82 'manage_own_calendar',
83 'manage_other_calendars',
84 'manage_other_availabilities',
85 'manage_all_data',
86 ];
87
88 /**
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.
93 *
94 * @param mixed $request
95 * @return true|\WP_Error
96 */
97 public static function readGate($request = null)
98 {
99 return self::transport($request);
100 }
101
102 /**
103 * permission_callback for the booking write tools.
104 *
105 * @param mixed $request
106 * @return true|\WP_Error
107 */
108 public static function bookingWriteGate($request = null)
109 {
110 return self::writeGate(self::BOOKING_WRITE_CAPS, __('Your account can read bookings but not change them.', 'fluent-booking'));
111 }
112
113 /**
114 * permission_callback for the scheduling configuration write tools.
115 *
116 * @param mixed $request
117 * @return true|\WP_Error
118 */
119 public static function scheduleWriteGate($request = null)
120 {
121 return self::writeGate(self::SCHEDULE_WRITE_CAPS, __('Your account can read scheduling settings but not change them.', 'fluent-booking'));
122 }
123
124 /**
125 * @param array $caps
126 * @param string $message
127 * @return true|\WP_Error
128 */
129 private static function writeGate($caps, $message)
130 {
131 $transport = self::transport();
132
133 if (is_wp_error($transport)) {
134 return $transport;
135 }
136
137 if (!PermissionManager::userCan($caps)) {
138 return MCPHelper::error('permission_denied', $message, ['required_any_of' => $caps]);
139 }
140
141 return true;
142 }
143
144 /**
145 * Calendar ids this caller may read, or false when they may read all of
146 * them.
147 *
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 * @return array|false false means "no restriction"
160 */
161 public static function readableCalendarIds()
162 {
163 static $cache = [];
164
165 $userId = get_current_user_id();
166
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.
174 $blogId = function_exists('get_current_blog_id') ? get_current_blog_id() : 0;
175
176 $key = $blogId . '|' . $userId . '|' . md5((string) wp_json_encode(PermissionManager::getUserPermissions()));
177
178 if (array_key_exists($key, $cache)) {
179 return $cache[$key];
180 }
181
182 if (PermissionManager::hasAllCalendarAccess(true)) {
183 return $cache[$key] = false;
184 }
185
186 return $cache[$key] = self::resolveReadableCalendarIds($userId);
187 }
188
189 /**
190 * The calendars a restricted user may read: the ones they own, plus the
191 * ones CalendarService::isSharedCalendar() would admit them to.
192 *
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.
197 *
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 * @param int $userId
203 *
204 * @return array
205 */
206 private static function resolveReadableCalendarIds($userId)
207 {
208 global $wpdb;
209
210 $calendars = $wpdb->prefix . (new Calendar())->getTable();
211 $events = $wpdb->prefix . (new CalendarSlot())->getTable();
212
213 $owned = $wpdb->get_col($wpdb->prepare("SELECT `id` FROM `{$calendars}` WHERE `user_id` = %d", $userId)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names come from the models' getTable(), not from request input
214
215 // isSharedCalendar()'s first branch: the user owns an event on it.
216 $hosting = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT `calendar_id` FROM `{$events}` WHERE `user_id` = %d", $userId)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names come from the models' getTable(), not from request input
217
218 $readable = array_merge(array_map('intval', $owned), array_map('intval', $hosting));
219
220 // and its second: the user is listed in an event's team_members.
221 $candidates = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names come from the models' getTable(), not from request input
222 $wpdb->prepare(
223 "SELECT `calendar_id`, `settings` FROM `{$events}` WHERE `settings` LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
224 '%' . $wpdb->esc_like('i:' . (int) $userId . ';') . '%'
225 ),
226 ARRAY_A
227 );
228
229 foreach ($candidates as $candidate) {
230 $calendarId = (int) $candidate['calendar_id'];
231
232 if (in_array($calendarId, $readable, true)) {
233 continue;
234 }
235
236 $settings = maybe_unserialize($candidate['settings']);
237
238 if (!is_array($settings)) {
239 continue;
240 }
241
242 $members = array_map('intval', (array) Arr::get($settings, 'team_members', []));
243
244 if (in_array((int) $userId, $members, true)) {
245 $readable[] = $calendarId;
246 }
247 }
248
249 return array_values(array_unique($readable));
250 }
251
252 /**
253 * Apply readableCalendarIds() to a query on any table with a calendar_id.
254 *
255 * @param object $query
256 * @param string $column
257 * @return object
258 */
259 public static function scopeToReadableCalendars($query, $column = 'calendar_id')
260 {
261 $ids = self::readableCalendarIds();
262
263 if ($ids === false) {
264 return $query;
265 }
266
267 // whereIn with an empty set must return nothing, not everything.
268 return $query->whereIn($column, $ids ? $ids : [0]);
269 }
270
271 /**
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.
276 *
277 * @return bool
278 */
279 public static function canSeeAllBookings()
280 {
281 return PermissionManager::userCanSeeAllBookings();
282 }
283
284 /**
285 * The scope marker for `meta.scope`, derived from the same check the query
286 * uses so the two can never disagree.
287 *
288 * @return string
289 */
290 public static function currentScope()
291 {
292 return self::canSeeAllBookings() ? MCPHelper::SCOPE_ALL : MCPHelper::SCOPE_OWN;
293 }
294
295 /**
296 * Transport gate for the `fluent-booking` server: may this request reach the
297 * endpoint at all?
298 *
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 * @param mixed $request unused; the adapter passes the REST request
306 * @return true|\WP_Error
307 */
308 public static function transport($request = null)
309 {
310 if (!self::isEnabled()) {
311 return MCPHelper::error(
312 'disabled',
313 __('The FluentBooking MCP server is disabled. Enable it in FluentBooking → Settings → MCP for AI Agents.', 'fluent-booking')
314 );
315 }
316
317 if (!is_user_logged_in()) {
318 return MCPHelper::error(
319 'unauthorized',
320 __('Authentication is required to access the FluentBooking MCP server.', 'fluent-booking')
321 );
322 }
323
324 if (!PermissionManager::currentUserHasAnyPermission()) {
325 return MCPHelper::error(
326 'forbidden',
327 __('Your account does not have FluentBooking access.', 'fluent-booking')
328 );
329 }
330
331 return true;
332 }
333
334 /**
335 * The stored MCP settings, defaults merged in.
336 *
337 * @param bool $cached
338 * @return array
339 */
340 public static function getSettings($cached = true)
341 {
342 static $settings = null;
343 static $forBlog = null;
344
345 // Keyed by blog: a mid-request site switch on multisite would otherwise
346 // hand the second site the first site's toolset selection.
347 $blogId = function_exists('get_current_blog_id') ? get_current_blog_id() : 0;
348
349 if ($cached && $settings !== null && $forBlog === $blogId) {
350 return $settings;
351 }
352
353 $forBlog = $blogId;
354
355 $stored = get_option(self::OPTION_KEY, []);
356
357 if (!is_array($stored)) {
358 $stored = [];
359 }
360
361 $settings = [
362 'enabled' => Arr::get($stored, 'enabled') === 'yes' ? 'yes' : 'no',
363 'toolsets' => self::sanitizeToolsets(Arr::get($stored, 'toolsets', [])),
364 ];
365
366 return $settings;
367 }
368
369 /**
370 * The master switch. Ships off.
371 *
372 * @return bool
373 */
374 public static function isEnabled()
375 {
376 $settings = self::getSettings();
377
378 return Arr::get($settings, 'enabled') === 'yes';
379 }
380
381 /**
382 * Persist the master switch.
383 *
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.
390 *
391 * @param bool $enabled
392 * @return bool the persisted state
393 */
394 public static function setEnabled($enabled)
395 {
396 if (!current_user_can('manage_options')) {
397 return self::isEnabled();
398 }
399
400 return self::saveSettings(['enabled' => $enabled ? 'yes' : 'no']);
401 }
402
403 /**
404 * Toolsets currently exposed. `core` is always present even if a stored
405 * value somehow omits it.
406 *
407 * @return array
408 */
409 public static function enabledToolsets()
410 {
411 $settings = self::getSettings();
412
413 return self::sanitizeToolsets(Arr::get($settings, 'toolsets', []));
414 }
415
416 /**
417 * @param string $toolset
418 * @return bool
419 */
420 public static function isToolsetEnabled($toolset)
421 {
422 return in_array($toolset, self::enabledToolsets(), true);
423 }
424
425 /**
426 * Persist toolset selection.
427 *
428 * @param array $toolsets
429 * @return array the persisted toolsets
430 */
431 public static function setToolsets($toolsets)
432 {
433 if (!current_user_can('manage_options')) {
434 return self::enabledToolsets();
435 }
436
437 self::saveSettings(['toolsets' => self::sanitizeToolsets($toolsets)]);
438
439 return self::enabledToolsets();
440 }
441
442 /**
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.
446 *
447 * @return array keyed by toolset slug
448 */
449 public static function availableToolsets()
450 {
451 $toolsets = [
452 self::TOOLSET_CORE => [
453 'label' => __('Core', 'fluent-booking'),
454 'description' => __('Bookings, availability, event types, diagnostics and reporting. Always on.', 'fluent-booking'),
455 'locked' => true,
456 ],
457 self::TOOLSET_SCHEDULING => [
458 'label' => __('Scheduling setup', 'fluent-booking'),
459 'description' => __('Let the agent create and edit event types and availability schedules.', 'fluent-booking'),
460 'locked' => false,
461 ],
462 ];
463
464 if (defined('FLUENT_BOOKING_PRO_DIR_FILE')) {
465 $toolsets[self::TOOLSET_PAYMENTS] = [
466 'label' => __('Payments', 'fluent-booking'),
467 'description' => __('Let the agent read booking orders and transactions.', 'fluent-booking'),
468 'locked' => false,
469 ];
470 }
471
472 return $toolsets;
473 }
474
475 /**
476 * Coerce a stored / submitted toolset list to known slugs, always including
477 * `core`.
478 *
479 * @param mixed $toolsets
480 * @return array
481 */
482 private static function sanitizeToolsets($toolsets)
483 {
484 if (!is_array($toolsets)) {
485 $toolsets = [];
486 }
487
488 $known = [self::TOOLSET_CORE, self::TOOLSET_SCHEDULING, self::TOOLSET_PAYMENTS];
489
490 $toolsets = array_values(array_intersect($known, array_map('sanitize_text_field', $toolsets)));
491
492 if (!in_array(self::TOOLSET_CORE, $toolsets, true)) {
493 array_unshift($toolsets, self::TOOLSET_CORE);
494 }
495
496 return $toolsets;
497 }
498
499 /**
500 * Merge-write into the option so setEnabled() and setToolsets() cannot
501 * clobber each other, then bust the static cache.
502 *
503 * @param array $changes
504 * @return bool the persisted enabled state
505 */
506 private static function saveSettings($changes)
507 {
508 $current = self::getSettings(false);
509
510 update_option(self::OPTION_KEY, array_merge($current, (array) $changes), true);
511
512 $settings = self::getSettings(false);
513
514 return Arr::get($settings, 'enabled') === 'yes';
515 }
516 }
517