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
fluent-booking / app / Modules / MCP / Support / PermissionGate.php

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

467 lines 15.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 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.
16 *
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.
24 *
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.
28 *
29 * Tool annotations (readonly / destructive) are client hints; this is the
30 * enforcement boundary.
31 */
32 class PermissionGate
33 {
34 /**
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.
39 */
40 const OPTION_KEY = '_fluent_booking_mcp_settings';
41
42 /**
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.
46 */
47 const TOOLSET_CORE = 'core';
48
49 const TOOLSET_SCHEDULING = 'scheduling';
50
51 const TOOLSET_PAYMENTS = 'payments';
52
53 /**
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.
57 */
58 const BOOKING_WRITE_CAPS = [
59 'manage_own_calendar',
60 'manage_all_bookings',
61 'manage_all_data',
62 ];
63
64 /**
65 * Permission sets that may change event types and availability schedules.
66 */
67 const SCHEDULE_WRITE_CAPS = [
68 'manage_own_calendar',
69 'manage_other_calendars',
70 'manage_other_availabilities',
71 'manage_all_data',
72 ];
73
74 /**
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.
77 *
78 * @param mixed $request
79 * @return true|\WP_Error
80 */
81 public static function readGate($request = null)
82 {
83 return self::transport($request);
84 }
85
86 /**
87 * permission_callback for the booking write tools.
88 *
89 * @param mixed $request
90 * @return true|\WP_Error
91 */
92 public static function bookingWriteGate($request = null)
93 {
94 return self::writeGate(self::BOOKING_WRITE_CAPS, __('Your account can read bookings but not change them.', 'fluent-booking'));
95 }
96
97 /**
98 * permission_callback for the scheduling configuration write tools.
99 *
100 * @param mixed $request
101 * @return true|\WP_Error
102 */
103 public static function scheduleWriteGate($request = null)
104 {
105 return self::writeGate(self::SCHEDULE_WRITE_CAPS, __('Your account can read scheduling settings but not change them.', 'fluent-booking'));
106 }
107
108 /**
109 * @param array $caps
110 * @param string $message
111 * @return true|\WP_Error
112 */
113 private static function writeGate($caps, $message)
114 {
115 $transport = self::transport();
116
117 if (is_wp_error($transport)) {
118 return $transport;
119 }
120
121 if (!PermissionManager::userCan($caps)) {
122 return MCPHelper::error('permission_denied', $message, ['required_any_of' => $caps]);
123 }
124
125 return true;
126 }
127
128 /**
129 * Calendar ids this caller may read, or false when they may read all of
130 * them. Every tool uses this so lists and detail reads agree on scope.
131 * Cached per request.
132 *
133 * @return array|false false means "no restriction"
134 */
135 public static function readableCalendarIds()
136 {
137 static $cache = [];
138
139 $userId = get_current_user_id();
140
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.
144 $blogId = function_exists('get_current_blog_id') ? get_current_blog_id() : 0;
145
146 $key = $blogId . '|' . $userId . '|' . md5((string) wp_json_encode(PermissionManager::getUserPermissions()));
147
148 if (array_key_exists($key, $cache)) {
149 return $cache[$key];
150 }
151
152 if (PermissionManager::hasAllCalendarAccess(true)) {
153 return $cache[$key] = false;
154 }
155
156 return $cache[$key] = self::resolveReadableCalendarIds($userId);
157 }
158
159 /**
160 * The calendars a restricted user may read: the ones they own, plus the
161 * ones CalendarService::isSharedCalendar() would admit them to. Three
162 * narrow queries instead of hydrating every calendar with its events.
163 *
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.
166 *
167 * @param int $userId
168 *
169 * @return array
170 */
171 private static function resolveReadableCalendarIds($userId)
172 {
173 global $wpdb;
174
175 $calendars = $wpdb->prefix . (new Calendar())->getTable();
176 $events = $wpdb->prefix . (new CalendarSlot())->getTable();
177
178 $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
179
180 // isSharedCalendar()'s first branch: the user owns an event on it.
181 $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
182
183 $readable = array_merge(array_map('intval', $owned), array_map('intval', $hosting));
184
185 // and its second: the user is listed in an event's team_members.
186 $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
187 $wpdb->prepare(
188 "SELECT `calendar_id`, `settings` FROM `{$events}` WHERE `settings` LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
189 '%' . $wpdb->esc_like('i:' . (int) $userId . ';') . '%'
190 ),
191 ARRAY_A
192 );
193
194 foreach ($candidates as $candidate) {
195 $calendarId = (int) $candidate['calendar_id'];
196
197 if (in_array($calendarId, $readable, true)) {
198 continue;
199 }
200
201 $settings = maybe_unserialize($candidate['settings']);
202
203 if (!is_array($settings)) {
204 continue;
205 }
206
207 $members = array_map('intval', (array) Arr::get($settings, 'team_members', []));
208
209 if (in_array((int) $userId, $members, true)) {
210 $readable[] = $calendarId;
211 }
212 }
213
214 return array_values(array_unique($readable));
215 }
216
217 /**
218 * Apply readableCalendarIds() to a query on any table with a calendar_id.
219 *
220 * @param object $query
221 * @param string $column
222 * @return object
223 */
224 public static function scopeToReadableCalendars($query, $column = 'calendar_id')
225 {
226 $ids = self::readableCalendarIds();
227
228 if ($ids === false) {
229 return $query;
230 }
231
232 // whereIn with an empty set must return nothing, not everything.
233 return $query->whereIn($column, $ids ? $ids : [0]);
234 }
235
236 /**
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.
239 *
240 * @return bool
241 */
242 public static function canSeeAllBookings()
243 {
244 return PermissionManager::userCanSeeAllBookings();
245 }
246
247 /**
248 * The `meta.scope` marker, from the same check the query uses.
249 *
250 * @return string
251 */
252 public static function currentScope()
253 {
254 return self::canSeeAllBookings() ? MCPHelper::SCOPE_ALL : MCPHelper::SCOPE_OWN;
255 }
256
257 /**
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.
261 *
262 * @param mixed $request unused; the adapter passes the REST request
263 * @return true|\WP_Error
264 */
265 public static function transport($request = null)
266 {
267 if (!self::isEnabled()) {
268 return MCPHelper::error(
269 'disabled',
270 __('The FluentBooking MCP server is disabled. Enable it in FluentBooking → Settings → MCP for AI Agents.', 'fluent-booking')
271 );
272 }
273
274 if (!is_user_logged_in()) {
275 return MCPHelper::error(
276 'unauthorized',
277 __('Authentication is required to access the FluentBooking MCP server.', 'fluent-booking')
278 );
279 }
280
281 if (!PermissionManager::currentUserHasAnyPermission()) {
282 return MCPHelper::error(
283 'forbidden',
284 __('Your account does not have FluentBooking access.', 'fluent-booking')
285 );
286 }
287
288 return true;
289 }
290
291 /**
292 * The stored MCP settings, defaults merged in.
293 *
294 * @param bool $cached
295 * @return array
296 */
297 public static function getSettings($cached = true)
298 {
299 static $settings = null;
300 static $forBlog = null;
301
302 // Keyed by blog, in case a multisite request switches sites.
303 $blogId = function_exists('get_current_blog_id') ? get_current_blog_id() : 0;
304
305 if ($cached && $settings !== null && $forBlog === $blogId) {
306 return $settings;
307 }
308
309 $forBlog = $blogId;
310
311 $stored = get_option(self::OPTION_KEY, []);
312
313 if (!is_array($stored)) {
314 $stored = [];
315 }
316
317 $settings = [
318 'enabled' => Arr::get($stored, 'enabled') === 'yes' ? 'yes' : 'no',
319 'toolsets' => self::sanitizeToolsets(Arr::get($stored, 'toolsets', [])),
320 ];
321
322 return $settings;
323 }
324
325 /**
326 * The master switch. Off by default.
327 *
328 * @return bool
329 */
330 public static function isEnabled()
331 {
332 $settings = self::getSettings();
333
334 return Arr::get($settings, 'enabled') === 'yes';
335 }
336
337 /**
338 * Persist the master switch.
339 *
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.
343 *
344 * @param bool $enabled
345 * @return bool the persisted state
346 */
347 public static function setEnabled($enabled)
348 {
349 if (!current_user_can('manage_options')) {
350 return self::isEnabled();
351 }
352
353 return self::saveSettings(['enabled' => $enabled ? 'yes' : 'no']);
354 }
355
356 /**
357 * Toolsets currently exposed. Always includes `core`.
358 *
359 * @return array
360 */
361 public static function enabledToolsets()
362 {
363 $settings = self::getSettings();
364
365 return self::sanitizeToolsets(Arr::get($settings, 'toolsets', []));
366 }
367
368 /**
369 * @param string $toolset
370 * @return bool
371 */
372 public static function isToolsetEnabled($toolset)
373 {
374 return in_array($toolset, self::enabledToolsets(), true);
375 }
376
377 /**
378 * Persist toolset selection.
379 *
380 * @param array $toolsets
381 * @return array the persisted toolsets
382 */
383 public static function setToolsets($toolsets)
384 {
385 if (!current_user_can('manage_options')) {
386 return self::enabledToolsets();
387 }
388
389 self::saveSettings(['toolsets' => self::sanitizeToolsets($toolsets)]);
390
391 return self::enabledToolsets();
392 }
393
394 /**
395 * Every toolset the server knows about, with its label. `payments` is only
396 * offered when Pro is active.
397 *
398 * @return array keyed by toolset slug
399 */
400 public static function availableToolsets()
401 {
402 $toolsets = [
403 self::TOOLSET_CORE => [
404 'label' => __('Core', 'fluent-booking'),
405 'description' => __('Bookings, availability, event types, diagnostics and reporting. Always on.', 'fluent-booking'),
406 'locked' => true,
407 ],
408 self::TOOLSET_SCHEDULING => [
409 'label' => __('Scheduling setup', 'fluent-booking'),
410 'description' => __('Let the agent create and edit event types and availability schedules.', 'fluent-booking'),
411 'locked' => false,
412 ],
413 ];
414
415 if (defined('FLUENT_BOOKING_PRO_DIR_FILE')) {
416 $toolsets[self::TOOLSET_PAYMENTS] = [
417 'label' => __('Payments', 'fluent-booking'),
418 'description' => __('Let the agent read booking orders and transactions.', 'fluent-booking'),
419 'locked' => false,
420 ];
421 }
422
423 return $toolsets;
424 }
425
426 /**
427 * Coerce a toolset list to known slugs, always including `core`.
428 *
429 * @param mixed $toolsets
430 * @return array
431 */
432 private static function sanitizeToolsets($toolsets)
433 {
434 if (!is_array($toolsets)) {
435 $toolsets = [];
436 }
437
438 $known = [self::TOOLSET_CORE, self::TOOLSET_SCHEDULING, self::TOOLSET_PAYMENTS];
439
440 $toolsets = array_values(array_intersect($known, array_map('sanitize_text_field', $toolsets)));
441
442 if (!in_array(self::TOOLSET_CORE, $toolsets, true)) {
443 array_unshift($toolsets, self::TOOLSET_CORE);
444 }
445
446 return $toolsets;
447 }
448
449 /**
450 * Merge-write into the option so setEnabled() and setToolsets() cannot
451 * clobber each other, then bust the static cache.
452 *
453 * @param array $changes
454 * @return bool the persisted enabled state
455 */
456 private static function saveSettings($changes)
457 {
458 $current = self::getSettings(false);
459
460 update_option(self::OPTION_KEY, array_merge($current, (array) $changes), true);
461
462 $settings = self::getSettings(false);
463
464 return Arr::get($settings, 'enabled') === 'yes';
465 }
466 }
467