PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.3
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.3
6.3 6.2.3 6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 All 157 releases
← All changes | includes/abilities/class-mainwp-abilities-clients.php +226 -13 6.2.3 → 6.3 View file →
@@ -159,8 +159,12 @@
159 159 * @param array $input Validated input parameters.
160 160 * @return array|\WP_Error Result array or error.
161 161 */
162 162 public static function execute_list_clients( array $input ) {
163 + // Restricted callers see the clients whose sites overlap their
164 + // allowed set instead of a blanket denial.
165 + $allowed_sites = MainWP_Abilities_Util::get_allowed_site_ids();
166 +
163 167 $input = MainWP_Abilities_Util::normalize_input(
164 168 $input,
165 169 array(
166 170 'page' => 1,
@@ -194,8 +198,26 @@
194 198 if ( ! empty( $input['exclude'] ) ) {
195 199 $args['exclude'] = $input['exclude'];
196 200 }
197 201
202 + if ( 'all' !== $allowed_sites ) {
203 + $accessible = MainWP_Abilities_Util::get_accessible_client_ids( $allowed_sites );
204 + $accessible = empty( $args['include'] )
205 + ? $accessible
206 + : array_values( array_intersect( array_map( 'intval', (array) $args['include'] ), $accessible ) );
207 +
208 + if ( empty( $accessible ) ) {
209 + return array(
210 + 'items' => array(),
211 + 'page' => (int) $input['page'],
212 + 'per_page' => (int) $input['per_page'],
213 + 'total' => 0,
214 + );
215 + }
216 +
217 + $args['include'] = $accessible;
218 + }
219 +
198 220 $db = MainWP_DB_Client::instance();
199 221 $clients = $db->get_wp_clients( $args );
200 222 $total = $db->get_wp_clients( array_merge( $args, array( 'count_only' => true ) ) );
201 223
@@ -275,10 +297,22 @@
275 297 * @param array $input Validated input parameters (unused but required by ability signature).
276 298 * @return array|\WP_Error Result array or error.
277 299 */
278 300 public static function execute_count_clients( array $input ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- NOSONAR Required by ability signature.
279 - $total = MainWP_DB_Client::instance()->get_wp_clients( array( 'count_only' => true ) );
301 + $allowed_sites = MainWP_Abilities_Util::get_allowed_site_ids();
280 302
303 + $args = array( 'count_only' => true );
304 +
305 + if ( 'all' !== $allowed_sites ) {
306 + $accessible = MainWP_Abilities_Util::get_accessible_client_ids( $allowed_sites );
307 + if ( empty( $accessible ) ) {
308 + return array( 'total' => 0 );
309 + }
310 + $args['include'] = $accessible;
311 + }
312 +
313 + $total = MainWP_DB_Client::instance()->get_wp_clients( $args );
314 +
281 315 return array(
282 316 'total' => (int) $total,
283 317 );
284 318 }
@@ -350,8 +384,14 @@
350 384 if ( is_wp_error( $client ) ) {
351 385 return $client;
352 386 }
353 387
388 + // Any-site overlap grants read access.
389 + $access = MainWP_Abilities_Util::check_client_access( $client, $input );
390 + if ( is_wp_error( $access ) ) {
391 + return $access;
392 + }
393 +
354 394 return MainWP_Abilities_Util::format_client_for_output( $client );
355 395 }
356 396
357 397 /**
@@ -368,9 +408,9 @@
368 408 'category' => 'mainwp-clients',
369 409 'input_schema' => self::get_add_client_input_schema(),
370 410 'output_schema' => self::get_client_output_schema(),
371 411 'execute_callback' => array( self::class, 'execute_add_client' ),
372 - 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
412 + 'permission_callback' => MainWP_Abilities_Util::make_operation_permission_callback( 'mainwp/add-client-v1', array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ) ),
373 413 'meta' => array(
374 414 'show_in_rest' => true,
375 415 'annotations' => array(
376 416 'instructions' => '',
@@ -418,8 +458,44 @@
418 458 );
419 459 }
420 460
421 461 /**
462 + * Reject selected_sites entries outside the caller's allowed set.
463 + *
464 + * A scoped user must not re-home sites they cannot access onto clients
465 + * they control, so any out-of-scope ID fails the whole call rather than
466 + * being silently dropped.
467 + *
468 + * @param array $input Ability input, possibly carrying selected_sites.
469 + * @return true|\WP_Error True when in scope, WP_Error otherwise.
470 + */
471 + public static function check_selected_sites_scope( array $input ) {
472 + if ( empty( $input['selected_sites'] ) || ! is_array( $input['selected_sites'] ) ) {
473 + return true;
474 + }
475 +
476 + $allowed_sites = MainWP_Abilities_Util::get_allowed_site_ids();
477 + if ( 'all' === $allowed_sites ) {
478 + return true;
479 + }
480 +
481 + $selected = array_map( 'intval', $input['selected_sites'] );
482 + $out_of_scope = array_diff( $selected, $allowed_sites );
483 +
484 + if ( ! empty( $out_of_scope ) ) {
485 + // Only a restricted caller reaches this leg, so the unseen site reads as missing.
486 + return new \WP_Error(
487 + 'mainwp_site_not_found',
488 + /* translators: %d: site ID */
489 + sprintf( __( 'No site found with ID %d.', 'mainwp' ), (int) reset( $out_of_scope ) ),
490 + array( 'status' => 404 )
491 + );
492 + }
493 +
494 + return true;
495 + }
496 +
497 + /**
422 498 * Execute add-client ability.
423 499 *
424 500 * @param array $input Validated input parameters.
425 501 * @return array|\WP_Error Result array or error.
@@ -424,8 +500,15 @@
424 500 * @param array $input Validated input parameters.
425 501 * @return array|\WP_Error Result array or error.
426 502 */
427 503 public static function execute_add_client( array $input ) {
504 + // Creation stays open to scoped callers, but they cannot attach
505 + // sites outside their allowed set.
506 + $scope_check = self::check_selected_sites_scope( $input );
507 + if ( is_wp_error( $scope_check ) ) {
508 + return $scope_check;
509 + }
510 +
428 511 if ( empty( $input['name'] ) ) {
429 512 return new \WP_Error(
430 513 'mainwp_invalid_input',
431 514 __( 'Client name is required.', 'mainwp' ),
@@ -484,9 +567,9 @@
484 567 'category' => 'mainwp-clients',
485 568 'input_schema' => self::get_update_client_input_schema(),
486 569 'output_schema' => self::get_client_output_schema(),
487 570 'execute_callback' => array( self::class, 'execute_update_client' ),
488 - 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
571 + 'permission_callback' => MainWP_Abilities_Util::make_operation_permission_callback( 'mainwp/update-client-v1', array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ) ),
489 572 'meta' => array(
490 573 'show_in_rest' => true,
491 574 'annotations' => array(
492 575 'instructions' => '',
@@ -547,8 +630,46 @@
547 630 if ( is_wp_error( $client ) ) {
548 631 return $client;
549 632 }
550 633
634 + // Any-site overlap grants the update; attaching sites outside the
635 + // caller's allowed set is rejected outright.
636 + $access = MainWP_Abilities_Util::check_client_access( $client, $input );
637 + if ( is_wp_error( $access ) ) {
638 + return $access;
639 + }
640 +
641 + $scope_check = self::check_selected_sites_scope( $input );
642 + if ( is_wp_error( $scope_check ) ) {
643 + return $scope_check;
644 + }
645 +
646 + // Preserve memberships the caller cannot see. update_selected_sites_for_client
647 + // replaces the client's whole roster, so a restricted caller submitting
648 + // selected_sites would silently detach the client's sites outside their allowed
649 + // set. Fold those existing out-of-scope sites back in: a partial manager can add
650 + // or remove only sites within their own scope, never detach ones they can't see.
651 + if ( isset( $input['selected_sites'] ) && is_array( $input['selected_sites'] ) ) {
652 + $allowed_sites = MainWP_Abilities_Util::get_allowed_site_ids();
653 + if ( 'all' !== $allowed_sites ) {
654 + $current_site_ids = array_map(
655 + static fn( $site ) => (int) $site->id,
656 + (array) MainWP_DB_Client::instance()->get_websites_by_client_ids(
657 + (int) $client->client_id,
658 + array( 'no_perm_check' => true )
659 + )
660 + );
661 + $unseen = array_diff( $current_site_ids, $allowed_sites );
662 + if ( ! empty( $unseen ) ) {
663 + $input['selected_sites'] = array_values(
664 + array_unique(
665 + array_merge( array_map( 'intval', $input['selected_sites'] ), $unseen )
666 + )
667 + );
668 + }
669 + }
670 + }
671 +
551 672 $data = $input;
552 673 $data['client_id'] = $client->client_id;
553 674
554 675 unset( $data['client_id_or_email'] );
@@ -594,9 +715,9 @@
594 715 'category' => 'mainwp-clients',
595 716 'input_schema' => self::get_delete_client_input_schema(),
596 717 'output_schema' => self::get_delete_client_output_schema(),
597 718 'execute_callback' => array( self::class, 'execute_delete_client' ),
598 - 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
719 + 'permission_callback' => MainWP_Abilities_Util::make_operation_permission_callback( 'mainwp/delete-client-v1', array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ) ),
599 720 'meta' => array(
600 721 'show_in_rest' => true,
601 722 'annotations' => array(
602 723 'instructions' => 'Destructive operation requires confirm: true. Only call when user explicitly requests deletion.',
@@ -685,9 +806,12 @@
685 806 'dry_run' => false,
686 807 )
687 808 );
688 809
689 - if ( $input['dry_run'] && $input['confirm'] ) {
810 + $dry_run = MainWP_Abilities_Util::get_boolean_input( $input, 'dry_run' );
811 + $confirm = MainWP_Abilities_Util::get_boolean_input( $input, 'confirm' );
812 +
813 + if ( $dry_run && $confirm ) {
690 814 return new \WP_Error(
691 815 'mainwp_invalid_input',
692 816 __( 'Cannot specify both dry_run and confirm. Use dry_run for preview, confirm for actual deletion.', 'mainwp' ),
693 817 array( 'status' => 400 )
@@ -699,9 +823,17 @@
699 823 if ( is_wp_error( $client ) ) {
700 824 return $client;
701 825 }
702 826
703 - if ( $input['dry_run'] ) {
827 + // Deleting removes the client for every site it covers, so the
828 + // caller's allowed set must cover all of them. Any-site overlap is
829 + // read access only.
830 + $access = MainWP_Abilities_Util::check_client_access( $client, $input, true );
831 + if ( is_wp_error( $access ) ) {
832 + return $access;
833 + }
834 +
835 + if ( $dry_run ) {
704 836 $sites = MainWP_DB_Client::instance()->get_websites_by_client_ids( $client->client_id );
705 837 $sites_count = is_array( $sites ) ? count( $sites ) : 0;
706 838
707 839 $warnings = array();
@@ -728,9 +860,9 @@
728 860 'warnings' => $warnings,
729 861 );
730 862 }
731 863
732 - if ( ! $input['confirm'] ) {
864 + if ( ! $confirm ) {
733 865 return new \WP_Error(
734 866 'mainwp_confirmation_required',
735 867 __( 'This is a destructive operation. Set confirm: true or use dry_run: true for preview.', 'mainwp' ),
736 868 array( 'status' => 400 )
@@ -820,13 +952,29 @@
820 952 if ( is_wp_error( $client ) ) {
821 953 return $client;
822 954 }
823 955
824 - $sites = MainWP_DB_Client::instance()->get_websites_by_client_ids( $client->client_id );
956 + $access = MainWP_Abilities_Util::check_client_access( $client, $input );
957 + if ( is_wp_error( $access ) ) {
958 + return $access;
959 + }
825 960
961 + $allowed_sites = MainWP_Abilities_Util::get_allowed_site_ids();
962 + $sites = MainWP_DB_Client::instance()->get_websites_by_client_ids(
963 + $client->client_id,
964 + array(
965 + 'no_perm_check' => true,
966 + 'where' => 'wp.is_staging = 0',
967 + )
968 + );
969 +
826 970 $items = array();
827 971 if ( is_array( $sites ) ) {
828 972 foreach ( $sites as $site ) {
973 + // A restricted caller sees only their slice of the client.
974 + if ( 'all' !== $allowed_sites && ! in_array( (int) $site->id, $allowed_sites, true ) ) {
975 + continue;
976 + }
829 977 $items[] = MainWP_Abilities_Util::format_site_for_output( $site );
830 978 }
831 979 }
832 980
@@ -906,11 +1054,32 @@
906 1054 if ( is_wp_error( $client ) ) {
907 1055 return $client;
908 1056 }
909 1057
910 - $sites = MainWP_DB_Client::instance()->get_websites_by_client_ids( $client->client_id );
911 - $count = is_array( $sites ) ? count( $sites ) : 0;
1058 + $access = MainWP_Abilities_Util::check_client_access( $client, $input );
1059 + if ( is_wp_error( $access ) ) {
1060 + return $access;
1061 + }
912 1062
1063 + $allowed_sites = MainWP_Abilities_Util::get_allowed_site_ids();
1064 + $sites = MainWP_DB_Client::instance()->get_websites_by_client_ids(
1065 + $client->client_id,
1066 + array(
1067 + 'no_perm_check' => true,
1068 + 'where' => 'wp.is_staging = 0',
1069 + )
1070 + );
1071 +
1072 + $count = 0;
1073 + if ( is_array( $sites ) ) {
1074 + foreach ( $sites as $site ) {
1075 + if ( 'all' !== $allowed_sites && ! in_array( (int) $site->id, $allowed_sites, true ) ) {
1076 + continue;
1077 + }
1078 + ++$count;
1079 + }
1080 + }
1081 +
913 1082 return array(
914 1083 'total' => $count,
915 1084 );
916 1085 }
@@ -1012,8 +1181,13 @@
1012 1181 if ( is_wp_error( $client ) ) {
1013 1182 return $client;
1014 1183 }
1015 1184
1185 + $access = MainWP_Abilities_Util::check_client_access( $client, $input );
1186 + if ( is_wp_error( $access ) ) {
1187 + return $access;
1188 + }
1189 +
1016 1190 if ( ! class_exists( 'MainWP\Dashboard\Module\CostTracker\Cost_Tracker_DB' ) ) {
1017 1191 return new \WP_Error(
1018 1192 'mainwp_internal_error',
1019 1193 __( 'Cost Tracker database class is not available.', 'mainwp' ),
@@ -1021,13 +1195,38 @@
1021 1195 );
1022 1196 }
1023 1197
1024 1198 $cost_db = \MainWP\Dashboard\Module\CostTracker\Cost_Tracker_DB::get_instance();
1025 - $costs = $cost_db->get_all_cost_trackers_by_clients( array( $client->client_id ) );
1199 + // The producer returns a client_id => cost[] map, not a flat list.
1200 + // get_cost_sites makes it stamp cost_sites_ids with the sites+groups+
1201 + // clients expansion the scope filter below needs.
1202 + $costs_map = $cost_db->get_all_cost_trackers_by_clients( array( $client->client_id ), array( 'get_cost_sites' => true ) );
1203 + $costs = is_array( $costs_map ) ? ( $costs_map[ (int) $client->client_id ] ?? array() ) : array();
1026 1204
1205 + // Any-site overlap grants the client read, but cost aggregates span
1206 + // all the client's sites. A partial manager only sees cost items
1207 + // backed by at least one site they can access, and company-wide
1208 + // items with no site attachment stay unrestricted-only.
1209 + $allowed_sites = MainWP_Abilities_Util::get_allowed_site_ids();
1210 +
1027 1211 $items = array();
1028 1212 if ( is_array( $costs ) ) {
1029 1213 foreach ( $costs as $cost ) {
1214 + if ( 'all' !== $allowed_sites ) {
1215 + // A cost reached through a group or client attachment
1216 + // carries an empty sites column; cost_sites_ids holds the
1217 + // resolved site list. Fall back to the raw column only if
1218 + // the producer did not stamp it.
1219 + if ( isset( $cost->cost_sites_ids ) && is_array( $cost->cost_sites_ids ) ) {
1220 + $cost_sites = array_map( 'intval', $cost->cost_sites_ids );
1221 + } else {
1222 + $cost_sites = ! empty( $cost->sites ) ? json_decode( $cost->sites, true ) : array();
1223 + $cost_sites = is_array( $cost_sites ) ? array_map( 'intval', $cost_sites ) : array();
1224 + }
1225 + if ( empty( array_intersect( $cost_sites, $allowed_sites ) ) ) {
1226 + continue;
1227 + }
1228 + }
1030 1229 $items[] = MainWP_Abilities_Util::format_cost_for_output( $cost );
1031 1230 }
1032 1231 }
1033 1232
@@ -1051,9 +1250,9 @@
1051 1250 'category' => 'mainwp-clients',
1052 1251 'input_schema' => self::get_suspend_client_input_schema(),
1053 1252 'output_schema' => self::get_client_output_schema(),
1054 1253 'execute_callback' => array( self::class, 'execute_suspend_client' ),
1055 - 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
1254 + 'permission_callback' => MainWP_Abilities_Util::make_operation_permission_callback( 'mainwp/suspend-client-v1', array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ) ),
1056 1255 'meta' => array(
1057 1256 'show_in_rest' => true,
1058 1257 'annotations' => array(
1059 1258 'instructions' => '',
@@ -1093,8 +1292,15 @@
1093 1292 if ( is_wp_error( $client ) ) {
1094 1293 return $client;
1095 1294 }
1096 1295
1296 + // Suspension affects every site the client covers: full coverage
1297 + // required, any-site overlap is read access only.
1298 + $access = MainWP_Abilities_Util::check_client_access( $client, $input, true );
1299 + if ( is_wp_error( $access ) ) {
1300 + return $access;
1301 + }
1302 +
1097 1303 $updated = MainWP_DB_Client::instance()->update_client(
1098 1304 array(
1099 1305 'client_id' => $client->client_id,
1100 1306 'suspended' => 1,
@@ -1136,9 +1342,9 @@
1136 1342 'category' => 'mainwp-clients',
1137 1343 'input_schema' => self::get_unsuspend_client_input_schema(),
1138 1344 'output_schema' => self::get_client_output_schema(),
1139 1345 'execute_callback' => array( self::class, 'execute_unsuspend_client' ),
1140 - 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
1346 + 'permission_callback' => MainWP_Abilities_Util::make_operation_permission_callback( 'mainwp/unsuspend-client-v1', array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ) ),
1141 1347 'meta' => array(
1142 1348 'show_in_rest' => true,
1143 1349 'annotations' => array(
1144 1350 'instructions' => '',
@@ -1176,8 +1382,15 @@
1176 1382 $client = MainWP_Abilities_Util::resolve_client( $input['client_id_or_email'] );
1177 1383
1178 1384 if ( is_wp_error( $client ) ) {
1179 1385 return $client;
1386 + }
1387 +
1388 + // Same full-coverage rule as suspend: the flip affects every site
1389 + // the client covers.
1390 + $access = MainWP_Abilities_Util::check_client_access( $client, $input, true );
1391 + if ( is_wp_error( $access ) ) {
1392 + return $access;
1180 1393 }
1181 1394
1182 1395 $updated = MainWP_DB_Client::instance()->update_client(
1183 1396 array(