PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
betterdocs / includes / Mcp / MCPGrants.php

MCPGrants.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.2, at includes/Mcp/MCPGrants.php

208 lines 5.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP grant invalidation when a user is deleted or demoted.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Mcp;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 /**
16 * An MCP grant is a credential that acts *as a person*. When that person is
17 * deleted, or demoted below the `edit_docs` floor {@see MCPServer::impersonate()}
18 * enforces, the grant has to stop being a credential.
19 *
20 * `MCPServer` already refuses such a call at request time with a typed
21 * `capability_missing`, so nothing unsafe happens without this class. What this
22 * adds is that the grant *disappears* — from `betterdocs_mcp_oauth`, and so from
23 * the admin's Connected Apps list. A dead grant sitting in a list an admin reads
24 * to answer "who can reach this site?" is a wrong answer wearing the shape of a
25 * right one, and a re-promoted user would silently get their old client back.
26 *
27 * Hooks, all after WordPress has finished the change:
28 *
29 * - `deleted_user` — revoke that user's OAuth grants; if the site's pairing
30 * token was minted by them, disconnect it too.
31 * - `set_user_role` — the user's roles were replaced.
32 * - `remove_user_role` — one role was taken away.
33 *
34 * The two role hooks re-ask `user_can( $id, 'edit_docs' )` rather than reasoning
35 * about the role that changed: a user may hold several roles, and only the
36 * resulting capability matters.
37 *
38 * @since 4.9.0
39 */
40 final class MCPGrants {
41
42 /**
43 * Whether the hooks are already attached.
44 *
45 * On a Pro site the container resolves two `Roles` objects and could resolve
46 * this more than once; revocation is destructive enough to be worth making
47 * literally-once rather than merely idempotent.
48 *
49 * @since 4.9.0
50 *
51 * @var bool
52 */
53 private static $hooked = false;
54
55 /**
56 * Attach the hooks. Called from `MCPManager::__construct()`.
57 *
58 * @since 4.9.0
59 *
60 * @return void
61 */
62 public static function init() {
63 if ( self::$hooked ) {
64 return;
65 }
66
67 self::$hooked = true;
68
69 add_action( 'deleted_user', [ __CLASS__, 'on_user_deleted' ] );
70 add_action( 'set_user_role', [ __CLASS__, 'on_role_changed' ] );
71 add_action( 'remove_user_role', [ __CLASS__, 'on_role_changed' ] );
72 }
73
74 /**
75 * A user was deleted: nothing they granted may survive them.
76 *
77 * @since 4.9.0
78 *
79 * @param int $user_id Deleted user.
80 * @return bool Whether anything was revoked.
81 */
82 public static function on_user_deleted( $user_id ) {
83 $user_id = (int) $user_id;
84
85 if ( $user_id <= 0 ) {
86 return false;
87 }
88
89 $revoked = self::revoke_oauth( $user_id );
90 $revoked = self::disconnect_pairing_of( $user_id ) || $revoked;
91
92 if ( $revoked ) {
93 self::log( sprintf( 'Revoked MCP grants for deleted user #%d.', $user_id ) );
94 }
95
96 return $revoked;
97 }
98
99 /**
100 * A user's roles changed: revoke only if they fell below the floor.
101 *
102 * Fires for both `set_user_role` and `remove_user_role`, which is why the
103 * second parameter is ignored — the question is never "which role went
104 * away?" but "can this user still be impersonated?".
105 *
106 * @since 4.9.0
107 *
108 * @param int $user_id User whose roles changed.
109 * @return bool Whether anything was revoked.
110 */
111 public static function on_role_changed( $user_id ) {
112 $user_id = (int) $user_id;
113
114 if ( $user_id <= 0 ) {
115 return false;
116 }
117
118 $capability = class_exists( __NAMESPACE__ . '\\MCPServer' )
119 ? MCPServer::IMPERSONATION_CAPABILITY
120 : 'edit_docs';
121
122 if ( user_can( $user_id, $capability ) ) {
123 return false;
124 }
125
126 $revoked = self::revoke_oauth( $user_id );
127 $revoked = self::disconnect_pairing_of( $user_id ) || $revoked;
128
129 if ( $revoked ) {
130 self::log(
131 sprintf(
132 'Revoked MCP grants for user #%1$d, who no longer holds "%2$s".',
133 $user_id,
134 $capability
135 )
136 );
137 }
138
139 return $revoked;
140 }
141
142 /**
143 * Drop every OAuth code, access token and refresh token issued to a user.
144 *
145 * @since 4.9.0
146 *
147 * @param int $user_id User id.
148 * @return bool Whether anything was removed.
149 */
150 private static function revoke_oauth( $user_id ) {
151 if ( ! class_exists( __NAMESPACE__ . '\\MCPOAuth' ) ) {
152 return false;
153 }
154
155 return (bool) MCPOAuth::revoke_user( $user_id );
156 }
157
158 /**
159 * Disconnect the site's pairing token when this user is the one it runs as.
160 *
161 * A pairing token is an admin-equivalent credential impersonating a user who
162 * no longer qualifies, so it goes — leaving it alive because it "only"
163 * belongs to a deleted account is how a site keeps answering MCP calls
164 * nobody owns.
165 *
166 * It goes with `disconnect( false )`, not the kill switch (ADR-042). The
167 * default `disconnect()` also calls `MCPOAuth::revoke_all()`, which is
168 * right for the admin pressing Disconnect and wrong here: this class has
169 * already revoked *this* user's grants with `revoke_user()`, and deleting
170 * one account must not disconnect everybody else's AI client from the site.
171 *
172 * @since 4.9.0
173 *
174 * @param int $user_id User id.
175 * @return bool Whether the pairing was disconnected.
176 */
177 private static function disconnect_pairing_of( $user_id ) {
178 if ( ! class_exists( __NAMESPACE__ . '\\MCPPairing' ) ) {
179 return false;
180 }
181
182 if ( ! MCPPairing::is_connected() || MCPPairing::user_id() !== (int) $user_id ) {
183 return false;
184 }
185
186 MCPPairing::disconnect( false );
187
188 return true;
189 }
190
191 /**
192 * WP_DEBUG-only diagnostic. Never logs a token or a code.
193 *
194 * @since 4.9.0
195 *
196 * @param string $message Message to log.
197 * @return void
198 */
199 private static function log( $message ) {
200 if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
201 return;
202 }
203
204 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
205 error_log( '[BD-MCP] ' . $message );
206 }
207 }
208