| @@ -133,8 +133,16 @@ | ||
| 133 | 133 | } |
| 134 | 134 | if ( 'v1' === $lane && 'orders' === $collection && is_wp_error( $permission ) && self::wc_filter( false, $context, $object_id, 'shop_order', 'orders', 'v1' ) ) { |
| 135 | 135 | return true; |
| 136 | 136 | } |
| 137 | + // The implicit v1 scope passes WooCommerce's grant through; ownership still rules. | |
| 138 | + if ( 'v1' === $lane && 'orders' === $collection && true === $permission && ! self::wc_filter( true, $context, $object_id, 'shop_order', 'orders', 'v1' ) ) { | |
| 139 | + return new \WP_Error( | |
| 140 | + "woocommerce_rest_cannot_{$context}", | |
| 141 | + __( 'Sorry, you are not allowed to edit this resource.', 'woocommerce' ), | |
| 142 | + array( 'status' => rest_authorization_required_code() ) | |
| 143 | + ); | |
| 144 | + } | |
| 137 | 145 | return $permission; |
| 138 | 146 | } finally { |
| 139 | 147 | if ( $restore ) { |
| 140 | 148 | $restore(); |
| @@ -215,28 +223,34 @@ | ||
| 215 | 223 | self::$rejudging_user_target = false; |
| 216 | 224 | } |
| 217 | 225 | } |
| 218 | 226 | } |
| 219 | - if ( ! $permission && 'shop_order' === $post_type && in_array( $collection, array( 'writes', 'orders' ), true ) ) { | |
| 227 | + if ( 'shop_order' === $post_type && in_array( $collection, array( 'writes', 'orders' ), true ) ) { | |
| 228 | + $ownership = self::ownership_applies( $lane, $context ); | |
| 229 | + $order = $ownership ? wc_get_order( $object_id ) : false; | |
| 230 | + if ( $permission ) { | |
| 231 | + // WooCommerce granted on its own signal — under HPOS with compatibility | |
| 232 | + // sync the post author is whoever created the order — but the cashier the | |
| 233 | + // order is assigned to is the owner, so a reassigned order still needs the | |
| 234 | + // `others` capability from its creator. | |
| 235 | + if ( $order instanceof \WC_Abstract_Order && ! self::owns_order( $order ) && ! current_user_can( "{$context}_others_shop_orders" ) ) { | |
| 236 | + $permission = false; | |
| 237 | + } | |
| 238 | + return $permission; | |
| 239 | + } | |
| 220 | 240 | // V1 checked existence before its fallback (23defd774); v2 did not. |
| 221 | 241 | if ( 'v1' === $lane && ( ! wc_get_order( $object_id ) || ! current_user_can( "{$context}_shop_orders" ) ) ) { |
| 222 | 242 | return $permission; |
| 223 | 243 | } |
| 224 | - // Without a post row, only v1 historically granted the flat edit cap. | |
| 244 | + // Edit is always ownership-aware (ORDER_RULES), so it has no flat fallback. | |
| 225 | 245 | $caps = array( |
| 226 | 246 | 'read' => 'read_private_shop_orders', |
| 227 | 247 | 'create' => 'publish_shop_orders', |
| 228 | - 'edit' => 'v1' === $lane ? 'edit_shop_orders' : null, | |
| 229 | 248 | 'delete' => 'delete_shop_orders', |
| 230 | 249 | ); |
| 231 | 250 | $cap = $caps[ $context ] ?? null; |
| 232 | - foreach ( self::ORDER_RULES as $rule ) { | |
| 233 | - if ( $lane === $rule['lane'] && $context === $rule['context'] && $rule['ownership'] ) { | |
| 234 | - $post = get_post( $object_id ); | |
| 235 | - if ( $post ) { | |
| 236 | - $cap = get_current_user_id() === (int) $post->post_author ? "{$context}_shop_orders" : "{$context}_others_shop_orders"; | |
| 237 | - } | |
| 238 | - } | |
| 251 | + if ( $order instanceof \WC_Abstract_Order ) { | |
| 252 | + $cap = self::owns_order( $order ) ? "{$context}_shop_orders" : "{$context}_others_shop_orders"; | |
| 239 | 253 | } |
| 240 | 254 | if ( $cap && current_user_can( $cap ) ) { |
| 241 | 255 | $permission = true; |
| 242 | 256 | } |
| @@ -248,8 +262,47 @@ | ||
| 248 | 262 | return $permission; |
| 249 | 263 | } |
| 250 | 264 | |
| 251 | 265 | /** |
| 266 | + * Whether ORDER_RULES makes this lane's context ownership-aware. | |
| 267 | + * | |
| 268 | + * @param string $lane Permission lane. | |
| 269 | + * @param string $context Permission context. | |
| 270 | + * @return bool | |
| 271 | + */ | |
| 272 | + private static function ownership_applies( string $lane, string $context ): bool { | |
| 273 | + foreach ( self::ORDER_RULES as $rule ) { | |
| 274 | + if ( $lane === $rule['lane'] && $context === $rule['context'] ) { | |
| 275 | + return (bool) $rule['ownership']; | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + return false; | |
| 280 | + } | |
| 281 | + | |
| 282 | + /** | |
| 283 | + * Whether the current user is the cashier an order is assigned to. | |
| 284 | + * | |
| 285 | + * `_pos_user` is the only ownership signal an order carries on both storage | |
| 286 | + * modes: the write lanes stamp it server-side on creation and move it on a | |
| 287 | + * cashier reassignment. `post_author` is not that signal — the posts store | |
| 288 | + * writes `1` for every order, and the HPOS placeholder row inherits whoever | |
| 289 | + * was logged in when it was inserted (the customer, or nobody, for a web | |
| 290 | + * order). An order without `_pos_user` (a web order) belongs to no cashier, | |
| 291 | + * so it needs the `*_others_shop_orders` capability. | |
| 292 | + * | |
| 293 | + * @param \WC_Abstract_Order $order Order being judged. | |
| 294 | + * @return bool | |
| 295 | + */ | |
| 296 | + private static function owns_order( \WC_Abstract_Order $order ): bool { | |
| 297 | + $cashier = $order->get_meta( '_pos_user' ); | |
| 298 | + $actor = get_current_user_id(); | |
| 299 | + | |
| 300 | + // The lanes stamp the canonical decimal string, so an exact match is the strict test. | |
| 301 | + return $actor > 0 && is_scalar( $cashier ) && (string) $cashier === (string) $actor; | |
| 302 | + } | |
| 303 | + | |
| 304 | + /** | |
| 252 | 305 | * Get the capabilities that identify protected staff accounts. |
| 253 | 306 | * |
| 254 | 307 | * @return array |
| 255 | 308 | */ |
| @@ -341,8 +394,51 @@ | ||
| 341 | 394 | |
| 342 | 395 | return static function () use ( $filter ): void { |
| 343 | 396 | remove_filter( 'woocommerce_shop_manager_editable_roles', $filter ); |
| 344 | 397 | }; |
| 398 | + } | |
| 399 | + | |
| 400 | + /** | |
| 401 | + * Apply the staff-account rule (#1918) wherever WordPress checks a user edit. | |
| 402 | + * | |
| 403 | + * Filters `map_meta_cap` so a till user below shop manager (holds | |
| 404 | + * `access_woocommerce_pos` but not `manage_woocommerce`) may edit, delete, | |
| 405 | + * remove or promote another account only when can_modify() allows it, on core, | |
| 406 | + * wc/v3 and wp-admin routes as well as the POS lanes. Promoting oneself needs | |
| 407 | + * `manage_options`. It only ever adds `do_not_allow`; it never grants. | |
| 408 | + * | |
| 409 | + * @param array $caps Primitive capabilities required. | |
| 410 | + * @param string $cap Capability being checked. | |
| 411 | + * @param int $user_id Acting user ID. | |
| 412 | + * @param array $args Extra arguments; `$args[0]` is the target user ID. | |
| 413 | + * | |
| 414 | + * @return array | |
| 415 | + */ | |
| 416 | + public static function map_user_meta_caps( $caps, $cap, $user_id, $args ): array { | |
| 417 | + $caps = (array) $caps; | |
| 418 | + if ( ! \in_array( $cap, array( 'edit_user', 'delete_user', 'remove_user', 'promote_user', 'edit_users', 'delete_users', 'promote_users' ), true ) ) { | |
| 419 | + return $caps; | |
| 420 | + } | |
| 421 | + if ( ! isset( $args[0] ) || ! is_numeric( $args[0] ) || (int) $args[0] < 1 ) { | |
| 422 | + return $caps; | |
| 423 | + } | |
| 424 | + $actor = (int) $user_id; | |
| 425 | + if ( ! user_can( $actor, 'access_woocommerce_pos' ) || user_can( $actor, 'manage_woocommerce' ) ) { | |
| 426 | + return $caps; | |
| 427 | + } | |
| 428 | + $target = (int) $args[0]; | |
| 429 | + if ( $actor === $target ) { | |
| 430 | + if ( \in_array( $cap, array( 'promote_user', 'promote_users' ), true ) && ! user_can( $actor, 'manage_options' ) ) { | |
| 431 | + $caps[] = 'do_not_allow'; | |
| 432 | + } | |
| 433 | + | |
| 434 | + return $caps; | |
| 435 | + } | |
| 436 | + if ( ! self::can_modify( $actor, $target ) ) { | |
| 437 | + $caps[] = 'do_not_allow'; | |
| 438 | + } | |
| 439 | + | |
| 440 | + return $caps; | |
| 345 | 441 | } |
| 346 | 442 | |
| 347 | 443 | /** |
| 348 | 444 | * Build the staff account permission error. |