| 1 |
<?php |
| 2 |
/** |
| 3 |
* Staff account protection for customer writes. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Services; |
| 9 |
|
| 10 |
/** |
| 11 |
* Decides which accounts a POS user may edit or delete. |
| 12 |
* |
| 13 |
* WooCommerce refuses credential changes on non-customer roles, but the old |
| 14 |
* WCPOS edit_users fallback bypassed that refusal, and WooCommerce's own test |
| 15 |
* reads only the target's FIRST role, so an administrator who also holds the |
| 16 |
* customer role passes it. This guard tests capabilities instead: a POS user |
| 17 |
* who is not an administrator may not edit or delete an account holding staff |
| 18 |
* capabilities, whatever its roles say. "Staff" covers site and store |
| 19 |
* administration, user management and an author seat in wp-admin, so editors |
| 20 |
* and authors are fenced as well as administrators, shop managers and other |
| 21 |
* cashiers. Reads are deliberately untouched — the POS customer space is every |
| 22 |
* user on the site (#1379). |
| 23 |
*/ |
| 24 |
class Customer_Account_Guard { |
| 25 |
/** |
| 26 |
* Get the capabilities that identify protected staff accounts. |
| 27 |
* |
| 28 |
* @return array |
| 29 |
*/ |
| 30 |
public static function protected_capabilities(): array { |
| 31 |
/* |
| 32 |
* Filters the capabilities that mark an account as staff. |
| 33 |
* |
| 34 |
* A POS user who cannot manage_options may not edit or delete an account |
| 35 |
* holding any of these. The default marks site and store administration |
| 36 |
* (manage_options, manage_woocommerce), user management (edit_users, which |
| 37 |
* the Cashier role holds) and an author seat in wp-admin (edit_posts: |
| 38 |
* editors, authors and contributors). Narrowing the list moves a target into |
| 39 |
* the cleared path. Cleared targets bypass WooCommerce's |
| 40 |
* woocommerce_shop_manager_editable_roles role-name restriction before |
| 41 |
* WooCommerce re-judges them. |
| 42 |
* |
| 43 |
* @param {array} $capabilities |
| 44 |
* @returns {array} $capabilities |
| 45 |
* @since 1.10.10 |
| 46 |
* @hook woocommerce_pos_protected_account_capabilities |
| 47 |
*/ |
| 48 |
$caps = apply_filters( 'woocommerce_pos_protected_account_capabilities', array( 'manage_options', 'manage_woocommerce', 'edit_users', 'edit_posts' ) ); |
| 49 |
|
| 50 |
return array_values( array_unique( array_filter( array_map( 'strval', (array) $caps ) ) ) ); |
| 51 |
} |
| 52 |
|
| 53 |
/** |
| 54 |
* Check staff protection before WooCommerce's own permission checks. |
| 55 |
* |
| 56 |
* Deny is the default for anything this method cannot resolve: it only ever |
| 57 |
* removes permission, so a caller that reaches it with a bad id is refused |
| 58 |
* rather than waved through. |
| 59 |
* |
| 60 |
* @param int $actor_id Acting user ID. |
| 61 |
* @param int $target_id Target user ID. |
| 62 |
* |
| 63 |
* @return bool |
| 64 |
*/ |
| 65 |
public static function can_modify( int $actor_id, int $target_id ): bool { |
| 66 |
if ( $actor_id < 1 || $target_id < 1 ) { |
| 67 |
return false; |
| 68 |
} |
| 69 |
if ( user_can( $actor_id, 'manage_options' ) || $actor_id === $target_id ) { |
| 70 |
return true; |
| 71 |
} |
| 72 |
$target = get_user_by( 'id', $target_id ); |
| 73 |
if ( ! $target ) { |
| 74 |
return false; |
| 75 |
} |
| 76 |
foreach ( self::protected_capabilities() as $cap ) { |
| 77 |
if ( user_can( $target, $cap ) ) { |
| 78 |
return false; |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
return true; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Let WooCommerce judge a cleared target by capability rather than role name. |
| 87 |
* |
| 88 |
* WooCommerce restricts a shop_manager to editing users whose role is in |
| 89 |
* `woocommerce_shop_manager_editable_roles` (default: customer only), so a |
| 90 |
* subscriber or a membership plugin's own customer role is refused outright |
| 91 |
* even though the POS lists it. Once can_modify() has cleared the target of |
| 92 |
* every staff capability, that role-name test adds nothing, so allow the |
| 93 |
* target's own roles for the duration of the check. |
| 94 |
* |
| 95 |
* Call only after can_modify() returned true, and always invoke the returned |
| 96 |
* closure to remove the filter. |
| 97 |
* |
| 98 |
* @param int $target_id Target user ID, already cleared by can_modify(). |
| 99 |
* |
| 100 |
* @return callable Restores the unfiltered behaviour. |
| 101 |
*/ |
| 102 |
public static function allow_target_roles( int $target_id ): callable { |
| 103 |
$target = get_user_by( 'id', $target_id ); |
| 104 |
$roles = $target ? array_values( (array) $target->roles ) : array(); |
| 105 |
|
| 106 |
if ( empty( $roles ) ) { |
| 107 |
return static function (): void {}; |
| 108 |
} |
| 109 |
|
| 110 |
$filter = static function ( $allowed ) use ( $roles ) { |
| 111 |
return array_values( array_unique( array_merge( (array) $allowed, $roles ) ) ); |
| 112 |
}; |
| 113 |
|
| 114 |
add_filter( 'woocommerce_shop_manager_editable_roles', $filter ); |
| 115 |
|
| 116 |
return static function () use ( $filter ): void { |
| 117 |
remove_filter( 'woocommerce_shop_manager_editable_roles', $filter ); |
| 118 |
}; |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Build the staff account permission error. |
| 123 |
* |
| 124 |
* @return \WP_Error |
| 125 |
*/ |
| 126 |
public static function denial(): \WP_Error { |
| 127 |
return new \WP_Error( |
| 128 |
'woocommerce_pos_rest_cannot_edit_staff_account', |
| 129 |
__( 'Only an administrator can edit or delete a staff account from the POS.', 'woocommerce-pos' ), |
| 130 |
array( 'status' => rest_authorization_required_code() ) |
| 131 |
); |
| 132 |
} |
| 133 |
} |
| 134 |
|