Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/fix-network-license-key-permission.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
significance: patch
type: fix
entry: On multisite, storing or deleting the network-wide license key through the REST API now requires the manage_network_options capability. A site administrator who sends network=true gets a 403 instead of changing the key for every site.
2 changes: 1 addition & 1 deletion docs/api/rest/license.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# License Endpoints

All endpoints require the `manage_options` capability.
All endpoints require the `manage_options` capability. On multisite, storing or deleting the network-level key (`network=true`) also requires `manage_network_options`; a site administrator gets a 403.

See [Licensing](../../subsystems/licensing.md) for the product entry data model, validation statuses, and key discovery workflows.

Expand Down
28 changes: 26 additions & 2 deletions src/Harbor/API/REST/V1/License_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@ public function register_routes(): void {
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'store_item' ],
'permission_callback' => [ $this, 'check_permissions' ],
'permission_callback' => [ $this, 'check_key_write_permissions' ],
'args' => $this->get_store_args(),
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_item' ],
'permission_callback' => [ $this, 'check_permissions' ],
'permission_callback' => [ $this, 'check_key_write_permissions' ],
'args' => $this->get_network_args(),
],
'schema' => [ $this, 'get_public_item_schema' ],
Expand Down Expand Up @@ -161,6 +161,30 @@ public function check_permissions(): bool {
return current_user_can( 'manage_options' );
}

/**
* Permission callback for storing or deleting the key.
*
* The network-level key applies to every site, so changing it also needs
* network-level authority.
*
* @since 2.0.2
*
* @param WP_REST_Request $request The request object.
*
* @return bool
*/
public function check_key_write_permissions( WP_REST_Request $request ): bool {
if ( ! $this->check_permissions() ) {
return false;
}

if ( is_multisite() && (bool) $request->get_param( 'network' ) ) {
return current_user_can( 'manage_network_options' );
}

return true;
}

/**
* Returns the current unified license key and its associated products.
*
Expand Down
2 changes: 1 addition & 1 deletion src/Harbor/Harbor.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Harbor {
*
* @var string
*/
public const VERSION = '2.0.1';
public const VERSION = '2.0.2';

/**
* Initializes the service provider.
Expand Down
126 changes: 126 additions & 0 deletions tests/muwpunit/API/REST/V1/License_ControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php declare( strict_types=1 );

namespace muwpunit\API\REST\V1;

use LiquidWeb\Harbor\API\REST\V1\License_Controller;
use LiquidWeb\Harbor\Licensing\License_Manager;
use LiquidWeb\Harbor\Licensing\Registry\Product_Registry;
use LiquidWeb\Harbor\Licensing\Repositories\License_Repository;
use LiquidWeb\Harbor\Site\Data;
use LiquidWeb\Harbor\Tests\HarborTestCase;
use LiquidWeb\Harbor\Tests\Licensing\Fixture_Client;
use LiquidWeb\Harbor\Tests\Traits\With_Uopz;
use WP_REST_Request;
use WP_REST_Server;

/**
* The network-level key applies to every site, so changing it needs network authority.
*
* @since 2.0.2
*/
final class License_ControllerTest extends HarborTestCase {

use With_Uopz;

private WP_REST_Server $server;

protected function setUp(): void {
parent::setUp();

delete_network_option( null, License_Repository::KEY_OPTION_NAME );
delete_option( License_Repository::KEY_OPTION_NAME );
delete_option( License_Repository::PRODUCTS_STATE_OPTION_NAME );
delete_option( License_Repository::VALIDATION_STATE_OPTION_NAME );

$manager = new License_Manager(
new License_Repository(),
new Product_Registry(),
new Fixture_Client( codecept_data_dir( 'licensing' ) )
);

/** @var WP_REST_Server $wp_rest_server */
global $wp_rest_server;
$wp_rest_server = new WP_REST_Server();
$this->server = $wp_rest_server;

// Allow registering routes outside of the rest_api_init hook.
$this->set_fn_return(
'did_action',
function ( $hook_name ) {
if ( $hook_name !== 'rest_api_init' ) {
return \did_action( $hook_name );
}
return true;
},
true
);

( new License_Controller( $manager, new Data() ) )->register_routes();
}

protected function tearDown(): void {
global $wp_rest_server;
$wp_rest_server = null;

delete_network_option( null, License_Repository::KEY_OPTION_NAME );
delete_option( License_Repository::KEY_OPTION_NAME );
delete_option( License_Repository::PRODUCTS_STATE_OPTION_NAME );
delete_option( License_Repository::VALIDATION_STATE_OPTION_NAME );

parent::tearDown();
}

public function test_site_admin_cannot_store_the_network_key(): void {
wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) );

$request = new WP_REST_Request( 'POST', '/liquidweb/harbor/v1/license' );
$request->set_param( 'key', 'LWSW-UNIFIED-PRO-2026' );
$request->set_param( 'network', true );
$response = $this->server->dispatch( $request );

$this->assertSame( 403, $response->get_status() );
$this->assertEmpty( get_network_option( null, License_Repository::KEY_OPTION_NAME ) );
}

public function test_site_admin_cannot_delete_the_network_key(): void {
update_network_option( null, License_Repository::KEY_OPTION_NAME, 'LWSW-UNIFIED-PRO-2026' );
wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) );

$request = new WP_REST_Request( 'DELETE', '/liquidweb/harbor/v1/license' );
$request->set_param( 'network', true );
$response = $this->server->dispatch( $request );

$this->assertSame( 403, $response->get_status() );
$this->assertSame( 'LWSW-UNIFIED-PRO-2026', get_network_option( null, License_Repository::KEY_OPTION_NAME ) );
}

public function test_site_admin_can_still_store_a_site_key(): void {
wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) );

$request = new WP_REST_Request( 'POST', '/liquidweb/harbor/v1/license' );
$request->set_param( 'key', 'LWSW-UNIFIED-PRO-2026' );
$response = $this->server->dispatch( $request );

$this->assertSame( 200, $response->get_status() );
$this->assertSame( 'LWSW-UNIFIED-PRO-2026', get_option( License_Repository::KEY_OPTION_NAME ) );
}

public function test_super_admin_can_store_and_delete_the_network_key(): void {
$user_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
grant_super_admin( $user_id );
wp_set_current_user( $user_id );

$store = new WP_REST_Request( 'POST', '/liquidweb/harbor/v1/license' );
$store->set_param( 'key', 'LWSW-UNIFIED-PRO-2026' );
$store->set_param( 'network', true );

$this->assertSame( 200, $this->server->dispatch( $store )->get_status() );
$this->assertSame( 'LWSW-UNIFIED-PRO-2026', get_network_option( null, License_Repository::KEY_OPTION_NAME ) );

$delete = new WP_REST_Request( 'DELETE', '/liquidweb/harbor/v1/license' );
$delete->set_param( 'network', true );

$this->assertSame( 204, $this->server->dispatch( $delete )->get_status() );
$this->assertEmpty( get_network_option( null, License_Repository::KEY_OPTION_NAME ) );
}
}
Loading