From 6f0330c5cf7cdd02fb0dde0c9c6dcc6ef4917a5b Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 30 Sep 2026 13:40:57 +0100 Subject: [PATCH] Add per-network super-admin scoping to REST sites controller (WIP) Co-Authored-By: Claude Sonnet 5 --- src/wp-includes/capabilities.php | 30 ++- .../class-wp-rest-sites-controller.php | 114 ++++++++- .../tests/rest-api/wpRestSitesController.php | 218 ++++++++++++++++++ tests/phpunit/tests/user/capabilities.php | 106 +++++++++ 4 files changed, 462 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 6f8b57d50b348..721ac4b434967 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1155,26 +1155,46 @@ function remove_role( $role ) { * Retrieves a list of super admins. * * @since 3.0.0 + * @since 7.2.0 Added the `$network_id` parameter. * * @global array $super_admins * + * @param int|null $network_id Optional. ID of the network. Default is the current network. * @return string[] List of super admin logins. */ -function get_super_admins() { +function get_super_admins( $network_id = null ) { global $super_admins; - return $super_admins ?? get_site_option( 'site_admins', array( 'admin' ) ); + // Falsy values (including 0) mean "unspecified", matching get_network_option()'s own fallback. + $is_current_network = ! $network_id || (int) $network_id === get_current_network_id(); + + if ( $is_current_network && isset( $super_admins ) ) { + return $super_admins; + } + + /* + * The 'admin' fallback exists for the current network only, where it reflects the + * install's original default super admin. A network whose `site_admins` option was + * never explicitly set has no default super admins - assuming 'admin' there would let + * any user with that login be treated as a super admin of a network they were never + * granted access to. + */ + $default = $is_current_network ? array( 'admin' ) : array(); + + return get_network_option( $network_id, 'site_admins', $default ); } /** * Determines whether user is a site admin. * * @since 3.0.0 + * @since 7.2.0 Added the `$network_id` parameter. * - * @param int|false $user_id Optional. The ID of a user. Defaults to false, to check the current user. + * @param int|false $user_id Optional. The ID of a user. Defaults to false, to check the current user. + * @param int|null $network_id Optional. ID of the network. Default is the current network. * @return bool Whether the user is a site admin. */ -function is_super_admin( $user_id = false ) { +function is_super_admin( $user_id = false, $network_id = null ) { if ( ! $user_id ) { $user = wp_get_current_user(); } else { @@ -1186,7 +1206,7 @@ function is_super_admin( $user_id = false ) { } if ( is_multisite() ) { - $super_admins = get_super_admins(); + $super_admins = get_super_admins( $network_id ); if ( is_array( $super_admins ) && in_array( $user->user_login, $super_admins, true ) ) { return true; } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php index 7c604035e34e4..cf80b69c7d09e 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php @@ -136,6 +136,30 @@ public function get_items_permissions_check( $request ) { return true; } + if ( isset( $request['network'] ) ) { + $network_check = $this->check_network_ids_exist( (array) $request['network'] ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( get_current_user_id(), (array) $request['network'] ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + + if ( isset( $request['network_exclude'] ) ) { + $network_check = $this->check_network_ids_exist( (array) $request['network_exclude'] ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( get_current_user_id(), (array) $request['network_exclude'] ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -408,6 +432,18 @@ public function get_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } + if ( $site->network_id > 0 ) { + $network_check = $this->check_network_ids_exist( (array) $site->network_id ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( get_current_user_id(), (array) $site->network_id ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'manage_sites' ) ) { return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -415,6 +451,55 @@ public function get_item_permissions_check( $request ) { return true; } + /** + * Validates that all network IDs in a request parameter exist. + * + * @since 7.2.0 + * + * @param int[] $network_ids Array of network IDs. + * @return true|WP_Error True if all network IDs are valid, WP_Error otherwise. + */ + protected function check_network_ids_exist( array $network_ids ) { + foreach ( $network_ids as $network_id ) { + if ( ! get_network( $network_id ) ) { + return new WP_Error( 'rest_network_id_invalid', __( 'Invalid network ID.' ), array( 'status' => 400 ) ); + } + } + + return true; + } + + /** + * Checks if a user can access sites on the given networks. + * + * A user can always access the current network. Access to any other + * network requires the user to be a super admin of that network. + * + * @since 7.2.0 + * + * @param int $user_id User ID. + * @param int[] $network_ids Array of network IDs. + * @return true|WP_Error True if the user can access all given networks, WP_Error otherwise. + */ + protected function check_network_access( $user_id, array $network_ids ) { + $current_network_id = get_current_network_id(); + if ( count( $network_ids ) === 1 && $current_network_id === $network_ids[0] ) { + return true; + } + + foreach ( $network_ids as $network_id ) { + if ( (int) $network_id === $current_network_id ) { + continue; + } + + if ( ! is_super_admin( $user_id, $network_id ) ) { + return new WP_Error( 'rest_cannot_view_network', __( 'Sorry, you are not allowed to access sites on this network.' ), array( 'status' => rest_authorization_required_code() ) ); + } + } + + return true; + } + /** * Retrieves a site. * @@ -452,6 +537,19 @@ public function create_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } + $network_id = isset( $request['network'] ) ? (int) $request['network'] : get_current_network_id(); + if ( $network_id > 0 ) { + $network_check = $this->check_network_ids_exist( (array) $network_id ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( get_current_user_id(), (array) $network_id ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + return current_user_can( 'create_sites' ); } @@ -569,7 +667,8 @@ public function update_item_permissions_check( $request ) { return $site; } - if ( 0 === get_current_user_id() ) { + $current_user_id = get_current_user_id(); + if ( 0 === $current_user_id ) { return false; } @@ -577,6 +676,19 @@ public function update_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } + $network_id = isset( $request['network'] ) ? (int) $request['network'] : $site->network_id; + if ( $network_id > 0 ) { + $network_check = $this->check_network_ids_exist( (array) $network_id ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( $current_user_id, (array) $network_id ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + if ( ! $this->check_edit_permission( $site ) ) { return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to edit this site.' ), array( 'status' => rest_authorization_required_code() ) ); } diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 621e534a9f86f..56803105558c6 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -1455,4 +1455,222 @@ public function test_get_items_filter_with_includes_user() { $this->assertCount( 1, $sites ); $this->assertEquals( array( $blog_ids[0] ), wp_list_pluck( $sites, 'id' ) ); } + + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::check_network_ids_exist + * @group ms-required + */ + public function test_get_items_permissions_check_invalid_network_id() { + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::check_network_ids_exist + * @group ms-required + */ + public function test_get_items_permissions_check_invalid_network_exclude_id() { + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'network_exclude', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_get_items_permissions_check_denies_access_to_another_network() { + wp_set_current_user( self::factory()->user->create() ); + $network_id = self::factory()->network->create(); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_get_items_permissions_check_allows_filtering_by_the_current_network() { + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'network', get_current_network_id() ); + + $response = rest_get_server()->dispatch( $request ); + + // The user still lacks manage_sites, but the network filter itself is not the reason. + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::create_item_permissions_check + * @covers ::check_network_ids_exist + * @group ms-required + */ + public function test_create_item_permissions_check_invalid_network_id() { + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/rejected/' ); + $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::create_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_create_item_permissions_check_denies_access_to_another_network() { + wp_set_current_user( self::factory()->user->create() ); + $network_id = self::factory()->network->create(); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/rejected/' ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::create_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_create_item_permissions_check_allows_a_super_admin_of_the_target_network() { + $user = self::factory()->user->create_and_get(); + $network_id = self::factory()->network->create(); + update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); + wp_set_current_user( $user->ID ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'network', $network_id ); + + $result = $this->endpoint->create_item_permissions_check( $request ); + + // The network check itself passes; any remaining denial is due to the unrelated create_sites capability. + $this->assertNotWPError( $result ); + } + + /** + * @ticket 40365 + * @covers ::update_item_permissions_check + * @covers ::check_network_ids_exist + * @group ms-required + */ + public function test_update_item_permissions_check_invalid_network_id() { + $blog_id = self::factory()->blog->create(); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::update_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_update_item_permissions_check_denies_moving_a_site_to_another_network() { + $blog_id = self::factory()->blog->create(); + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::update_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_update_item_permissions_check_allows_the_sites_own_network_by_default() { + $blog_id = self::factory()->blog->create(); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * @ticket 40365 + * @covers ::get_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_get_item_permissions_check_denies_a_site_on_another_network() { + wp_set_current_user( self::$superadmin_id ); + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + $blog_id = self::factory()->blog->create( array( 'site_id' => $network_id ) ); + + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::get_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_get_item_permissions_check_allows_a_site_on_the_current_network() { + $blog_id = self::factory()->blog->create(); + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } } diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php index 30f6a2144a02b..4185c518a2553 100644 --- a/tests/phpunit/tests/user/capabilities.php +++ b/tests/phpunit/tests/user/capabilities.php @@ -2594,4 +2594,110 @@ public function test_role_capabilities_updated_correctly_via_update_option() { $this->assertSameSetsWithIndex( $emcee_caps, $sally_caps, 'Emcee and Sally roles should have the same capabilities after update.' ); $this->assertLessThan( $emcee_queries, $sally_queries, 'Updating roles via update_option should be more efficient than WP_Roles using the database.' ); } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::get_super_admins + */ + public function test_get_super_admins_with_explicit_current_network_id_matches_default() { + $this->assertSameSets( get_super_admins(), get_super_admins( get_current_network_id() ) ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::get_super_admins + */ + public function test_get_super_admins_for_another_network_returns_that_networks_site_admins() { + $network_id = self::factory()->network->create(); + update_network_option( $network_id, 'site_admins', array( 'someone-else' ) ); + + $this->assertSameSets( array( 'someone-else' ), get_super_admins( $network_id ) ); + $this->assertNotSame( get_super_admins(), get_super_admins( $network_id ) ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::get_super_admins + */ + public function test_get_super_admins_for_another_network_without_a_site_admins_option_is_empty() { + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + + // Unlike the current network, a network with no `site_admins` option has no implicit 'admin' fallback. + $this->assertSame( array(), get_super_admins( $network_id ) ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::get_super_admins + */ + public function test_get_super_admins_global_override_does_not_apply_to_other_networks() { + $network_id = self::factory()->network->create(); + $had_global_override = array_key_exists( 'super_admins', $GLOBALS ); + $old_super_admins = $had_global_override ? $GLOBALS['super_admins'] : null; + delete_network_option( $network_id, 'site_admins' ); + update_network_option( $network_id, 'site_admins', array( 'network-admin' ) ); + + $GLOBALS['super_admins'] = array( 'global-override' ); + + $this->assertSameSets( array( 'global-override' ), get_super_admins() ); + $this->assertSameSets( array( 'network-admin' ), get_super_admins( $network_id ) ); + + if ( $had_global_override ) { + $GLOBALS['super_admins'] = $old_super_admins; + } else { + unset( $GLOBALS['super_admins'] ); + } + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::is_super_admin + */ + public function test_is_super_admin_defaults_to_the_current_network() { + $user = self::$super_admin; + + $this->assertTrue( is_super_admin( $user->ID ) ); + $this->assertTrue( is_super_admin( $user->ID, get_current_network_id() ) ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::is_super_admin + */ + public function test_is_super_admin_for_a_network_the_user_is_not_a_super_admin_of() { + $user = self::$super_admin; + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + + $this->assertTrue( is_super_admin( $user->ID ), 'User is expected to be a super admin of the current network.' ); + $this->assertFalse( is_super_admin( $user->ID, $network_id ), 'User is not expected to be a super admin of an unrelated network.' ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::is_super_admin + */ + public function test_is_super_admin_for_a_network_the_user_is_a_super_admin_of() { + $user = self::factory()->user->create_and_get(); + $network_id = self::factory()->network->create(); + update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); + + $this->assertFalse( is_super_admin( $user->ID ), 'User is not expected to be a super admin of the current network.' ); + $this->assertTrue( is_super_admin( $user->ID, $network_id ), 'User is expected to be a super admin of the network granting them access.' ); + } }