PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / policies / PluginAdminAccessPolicy.php

PluginAdminAccessPolicy.php in 404 Solution trunk, at includes/policies/PluginAdminAccessPolicy.php

422 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Authorization policy: decides whether the current WordPress user
9 * counts as a "plugin admin" for 404 Solution and grants the
10 * `manage_options` capability on the plugin's own admin screens via
11 * the `user_has_cap` filter.
12 *
13 * Owns logic previously hosted on PluginLogic (userIsPluginAdmin and
14 * the static override_user_can_access_admin_page filter callback).
15 * Composed through abj_service('admin_access_policy'). The static
16 * `wpUserHasCapFilter` is wired into WordPress by PluginLogic during
17 * bootstrap so the filter signature matches WP's expectations.
18 */
19 class ABJ_404_Solution_PluginAdminAccessPolicy {
20
21 /**
22 * Avoid infinite recursion when current_user_can() re-enters via filter.
23 * @var bool
24 */
25 private static $checkingIsAdmin = false;
26
27 /** @var self|null */
28 private static $instance = null;
29
30 /** @var object|null */
31 private $optionsRepo;
32
33 /** @var object|null */
34 private $functions;
35
36 /** @var object|null */
37 private $logger;
38
39 /**
40 * @param object|null $optionsRepo Anything responding to getOptions($skipDbCheck)
41 * @param object|null $functions Functions facade (removeEmptyCustom, explodeNewline)
42 * @param object|null $logger Logger facade (debugMessage)
43 */
44 public function __construct($optionsRepo = null, $functions = null, $logger = null) {
45 $this->optionsRepo = $optionsRepo;
46 $this->functions = $functions;
47 $this->logger = $logger;
48 }
49
50 /** @return self */
51 public static function getInstance(): self {
52 if (self::$instance !== null) {
53 return self::$instance;
54 }
55 self::$instance = new self();
56 return self::$instance;
57 }
58
59 /**
60 * Test-seam: install an externally-constructed instance, or pass null to
61 * clear the cached singleton. Accepting null keeps a uniform contract with
62 * the other singletons' setInstance() seams (M105 singleton-reset).
63 *
64 * @param self|null $instance
65 * @return void
66 */
67 public static function setInstance(?self $instance): void {
68 self::$instance = $instance;
69 }
70
71 /** Test-seam reset. @return void */
72 public static function reset(): void {
73 self::$instance = null;
74 self::$checkingIsAdmin = false;
75 }
76
77 /**
78 * Resolve the registered policy and return the current plugin-admin decision.
79 *
80 * Production call sites should use this accessor instead of reading
81 * WordPress capabilities directly. The method fails closed when service
82 * resolution is unavailable and logs the underlying context where possible.
83 *
84 * @return bool
85 */
86 public static function currentUserCanAccessPluginAdmin(): bool {
87 try {
88 $policy = function_exists('abj_service_optional') ? abj_service_optional('admin_access_policy') : null;
89 if (is_object($policy) && method_exists($policy, 'isPluginAdmin')) {
90 return (bool)$policy->isPluginAdmin();
91 }
92
93 self::warnStaticPolicyResolutionFailure(
94 'plugin admin access policy resolution failed because admin_access_policy is unavailable.'
95 );
96 return false;
97 } catch (\Throwable $e) {
98 self::warnStaticPolicyResolutionFailure('plugin admin access policy resolution failed', $e);
99 return false;
100 }
101 }
102
103 /**
104 * Whether the current WP user qualifies as a 404 Solution admin.
105 *
106 * Capability sources, ORed together:
107 * - manage_options or the administrator role
108 * - multisite super admin
109 * - presence in the plugin's plugin_admin_users option (login match)
110 *
111 * The result then runs through the abj404_userIsPluginAdmin filter
112 * so site owners can override either direction.
113 *
114 * Recursion-safe: re-entry returns false to avoid the filter chain
115 * looping back into itself.
116 *
117 * @return bool
118 */
119 public function isPluginAdmin(): bool {
120 if (self::$checkingIsAdmin) {
121 return false;
122 }
123
124 self::$checkingIsAdmin = true;
125 try {
126 $options = $this->loadOptions();
127 $logger = $this->logger !== null ? $this->logger : abj_service('logging');
128 $logger = is_object($logger) ? $logger : null;
129 $currentUserName = $this->currentUserName();
130 $capability = $this->currentUserAdminCapability();
131
132 $isPluginAdmin = $capability['is_plugin_admin'] ||
133 $this->currentUserIsListedPluginAdmin($options, $currentUserName);
134
135 $filtered = apply_filters('abj404_userIsPluginAdmin', $isPluginAdmin);
136
137 $this->logPluginAdminDecision(
138 $logger,
139 $options,
140 $currentUserName,
141 $capability['can_manage_options'],
142 $isPluginAdmin,
143 (bool) $filtered
144 );
145
146 return (bool) $filtered;
147 } finally {
148 self::$checkingIsAdmin = false;
149 }
150 }
151
152 /**
153 * @return array<string, mixed>
154 */
155 private function loadOptions(): array {
156 $optionsRepo = $this->optionsRepo !== null ? $this->optionsRepo : abj_service('options_repository');
157 if (!is_object($optionsRepo) || !method_exists($optionsRepo, 'getOptions')) {
158 return array();
159 }
160
161 try {
162 if (method_exists($optionsRepo, 'getPluginAdminUsersOption')) {
163 return array(
164 'plugin_admin_users' => $optionsRepo->getPluginAdminUsersOption(),
165 );
166 }
167 $resolvedOptions = $optionsRepo->getOptions(true);
168 return is_array($resolvedOptions) ? $resolvedOptions : array();
169 } catch (\Throwable $e) {
170 $this->warn('plugin admin option lookup failed (code ' .
171 $e->getCode() . '): ' . $e->getMessage());
172 return array();
173 }
174 }
175
176 /**
177 * @return array{can_manage_options: bool, is_plugin_admin: bool}
178 */
179 private function currentUserAdminCapability(): array {
180 $canManageOptions = false;
181 $hasAdministratorRole = false;
182 try {
183 $canManageOptions = function_exists('current_user_can') && current_user_can('manage_options');
184 $hasAdministratorRole = function_exists('current_user_can') && current_user_can('administrator');
185 } catch (\Throwable $e) {
186 $this->warn('plugin admin capability lookup failed (code ' .
187 $e->getCode() . '): ' . $e->getMessage());
188 }
189
190 $isPluginAdmin = $canManageOptions || $hasAdministratorRole;
191 if (function_exists('is_multisite') && is_multisite() && function_exists('is_super_admin') && is_super_admin()) {
192 $isPluginAdmin = true;
193 }
194
195 return array(
196 'can_manage_options' => $canManageOptions,
197 'is_plugin_admin' => $isPluginAdmin,
198 );
199 }
200
201 /** @return string|null */
202 private function currentUserName() {
203 global $current_user;
204 if (!isset($current_user) || !isset($current_user->user_login)) {
205 return null;
206 }
207
208 return is_string($current_user->user_login) ? $current_user->user_login : null;
209 }
210
211 /**
212 * @param array<string, mixed> $options
213 */
214 private function currentUserIsListedPluginAdmin(array $options, ?string $currentUserName): bool {
215 if ($currentUserName === null || $currentUserName === '') {
216 return false;
217 }
218
219 $extraAdmins = isset($options['plugin_admin_users']) ? $options['plugin_admin_users'] : array();
220 $extraAdmins = $this->normalizeExtraAdmins($extraAdmins);
221 return in_array($currentUserName, $extraAdmins, true);
222 }
223
224 /**
225 * @param mixed $extraAdmins
226 * @return array<int, string>
227 */
228 private function normalizeExtraAdmins($extraAdmins): array {
229 $functions = $this->functions !== null ? $this->functions : abj_service('functions');
230
231 if (is_array($extraAdmins)) {
232 if (is_object($functions) && method_exists($functions, 'removeEmptyCustom')) {
233 $extraAdmins = array_filter($extraAdmins, array($functions, 'removeEmptyCustom'));
234 } else {
235 $extraAdmins = array_filter($extraAdmins);
236 }
237 return $this->stringList($extraAdmins);
238 }
239
240 if (is_string($extraAdmins) && is_object($functions) && method_exists($functions, 'explodeNewline')) {
241 $splitAdmins = $functions->explodeNewline($extraAdmins);
242 return is_array($splitAdmins) ? $this->stringList(array_filter($splitAdmins)) : array();
243 }
244
245 return array();
246 }
247
248 /**
249 * @param array<int|string, mixed> $values
250 * @return array<int, string>
251 */
252 private function stringList(array $values): array {
253 $strings = array();
254 foreach ($values as $value) {
255 if (is_scalar($value)) {
256 $strings[] = (string) $value;
257 }
258 }
259 return $strings;
260 }
261
262 /**
263 * @param object|null $logger
264 * @param array<string, mixed> $options
265 * @return void
266 */
267 private function logPluginAdminDecision(?object $logger, array $options, ?string $currentUserName, bool $canManageOptions, bool $isPluginAdmin, bool $filtered): void {
268 if (($filtered && $filtered === $isPluginAdmin) || !is_object($logger) || !method_exists($logger, 'debugMessage')) {
269 return;
270 }
271
272 $logger->debugMessage(
273 "userIsPluginAdmin detail: result=" . ($filtered ? 'true' : 'false') .
274 ", pre-filter=" . ($isPluginAdmin ? 'true' : 'false') .
275 ", manage_options=" . ($canManageOptions ? 'yes' : 'no') .
276 ", user=" . ($currentUserName !== null ? $currentUserName : '(none)') .
277 ", plugin_admin_users=[" . esc_html($this->extraAdminsSummary($options)) . "]" .
278 ($filtered !== $isPluginAdmin ? ", NOTE: abj404_userIsPluginAdmin filter changed the result" : "")
279 );
280 }
281
282 /**
283 * @param array<string, mixed> $options
284 */
285 private function extraAdminsSummary(array $options): string {
286 $rawExtra = isset($options['plugin_admin_users']) ? $options['plugin_admin_users'] : array();
287 if (is_string($rawExtra)) {
288 return $rawExtra;
289 }
290 if (!is_array($rawExtra)) {
291 return '';
292 }
293
294 return implode(', ', $this->normalizeExtraAdmins($rawExtra));
295 }
296
297 private function warn(string $message): void {
298 $logger = $this->logger !== null ? $this->logger : (function_exists('abj_service_optional') ? abj_service_optional('logging') : null);
299 if (is_object($logger) && method_exists($logger, 'warn')) {
300 $logger->warn($message);
301 return;
302 }
303
304 abj404_logPhpFallback('service-resolution-fallback', $message);
305 }
306
307 private static function warnStaticPolicyResolutionFailure(string $message, ?\Throwable $throwable = null): void {
308 if (function_exists('abj404_logRuntimeWarning')) {
309 abj404_logRuntimeWarning($message, $throwable);
310 return;
311 }
312
313 $line = $message;
314 if ($throwable !== null) {
315 $line .= ' (code ' . (string)$throwable->getCode() . ') at ' .
316 $throwable->getFile() . ':' . (string)$throwable->getLine() .
317 ': ' . $throwable->getMessage();
318 }
319 abj404_logPhpFallback('service-resolution-fallback', $line);
320 }
321
322 /**
323 * `user_has_cap` filter callback: while a plugin admin is viewing one
324 * of the plugin's own admin screens, grant `manage_options` so WP's
325 * capability gate lets them through even if their WP role does not.
326 *
327 * Static because WordPress invokes filter callbacks by name with a
328 * fixed (allcaps, caps, args, user) signature.
329 *
330 * @param array<string, bool> $allcaps
331 * @param array<int, string> $caps
332 * @param array<int, mixed> $args
333 * @param \WP_User $user
334 * @return array<string, bool>
335 */
336 public static function wpUserHasCapFilter($allcaps, $caps, $args, $user) {
337 if (!is_admin()) {
338 return $allcaps;
339 }
340
341 try {
342 $policy = abj_service('admin_access_policy');
343 if (!is_object($policy) || !method_exists($policy, 'isPluginAdmin')) {
344 return $allcaps;
345 }
346
347 if (!$policy->isPluginAdmin()) {
348 return $allcaps;
349 }
350
351 if (self::isRequestForPluginAdminPage()) {
352 $allcaps['manage_options'] = true;
353 }
354 } catch (\Throwable $e) {
355 // user_has_cap fires on every current_user_can() check across
356 // all of wp-admin, often many times per page load. A transient
357 // failure resolving this plugin's services must not fatal the
358 // whole admin page for an unrelated capability check; returning
359 // $allcaps unchanged is also the safe failure mode for an
360 // authorization filter (it withholds the extra grant rather
361 // than risking one).
362 if (function_exists('abj404_logRuntimeWarning')) {
363 abj404_logRuntimeWarning('wpUserHasCapFilter failed', $e);
364 }
365 }
366
367 return $allcaps;
368 }
369
370 /**
371 * Whether the current request is actually loading THIS plugin's own admin
372 * screen. Scopes the manage_options grant above as narrowly as the
373 * user_has_cap filter allows.
374 *
375 * Binds the two signals WordPress itself uses to route an admin page:
376 * - the `page` query var must EXACTLY equal the plugin page slug
377 * (ABJ404_PP), not merely contain it as a substring, AND
378 * - the current admin file ($pagenow) must be the parent the plugin
379 * registers its menu under: 'admin.php' for the top-level menu
380 * (menuLocation=settingsLevel) or 'options-general.php' for the
381 * Settings submenu (see WordPress_Connector::addMainSettingsPageLink).
382 *
383 * Without both, a delegated plugin admin (a user listed in
384 * plugin_admin_users who lacks manage_options) could decorate any wp-admin
385 * URL with the plugin slug and pick up manage_options request-wide:
386 * e.g. users.php?s=abj404_solution (slug as a substring) or
387 * options.php?page=abj404_solution (right slug, wrong admin file).
388 *
389 * @return bool
390 */
391 private static function isRequestForPluginAdminPage(): bool {
392 if (self::currentAdminPageQueryVar() !== ABJ404_PP) {
393 return false;
394 }
395
396 $pagenow = isset($GLOBALS['pagenow']) && is_string($GLOBALS['pagenow'])
397 ? $GLOBALS['pagenow'] : '';
398
399 return $pagenow === 'admin.php' || $pagenow === 'options-general.php';
400 }
401
402 /**
403 * Resolve the `page` admin query var from the sanitized UserRequest query
404 * string (the same source the filter has always trusted), or null when it
405 * is absent or cannot be determined.
406 *
407 * @return string|null
408 */
409 private static function currentAdminPageQueryVar(): ?string {
410 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
411 $queryParts = $userRequest !== null ? $userRequest->getQueryString() : null;
412 if (!is_string($queryParts) || $queryParts === '') {
413 return null;
414 }
415
416 $parsed = array();
417 parse_str($queryParts, $parsed);
418
419 return isset($parsed['page']) && is_string($parsed['page']) ? $parsed['page'] : null;
420 }
421 }
422