| @@ -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,148 @@ | ||
| 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; | |
| 441 | + } | |
| 442 | + | |
| 443 | + /** | |
| 444 | + * The only role a till user may assign. | |
| 445 | + * | |
| 446 | + * Customer creation from the till always produces this role; changing a | |
| 447 | + * role is not a POS feature, so nothing legitimate needs more. | |
| 448 | + */ | |
| 449 | + private const TILL_ASSIGNABLE_ROLES = array( 'customer' ); | |
| 450 | + | |
| 451 | + /** | |
| 452 | + * The roles a POS actor may hand out, or null when the actor is not fenced. | |
| 453 | + * | |
| 454 | + * Administrators (and so multisite super admins) are not fenced. A shop | |
| 455 | + * manager — any actor with `manage_woocommerce` — follows WooCommerce's own | |
| 456 | + * list for shop managers, `woocommerce_shop_manager_editable_roles` | |
| 457 | + * (customer by default), which WooCommerce enforces only while it is active | |
| 458 | + * and only for the literal `shop_manager` role name; applying it here keeps | |
| 459 | + * that fence up when WooCommerce is deactivated (the roles and their | |
| 460 | + * capabilities persist) and for a cashier who also holds shop manager. | |
| 461 | + * Everyone else with till access gets TILL_ASSIGNABLE_ROLES. | |
| 462 | + * | |
| 463 | + * @param int $actor Acting user ID. | |
| 464 | + * | |
| 465 | + * @return array|null Role names, or null for an unfenced actor. | |
| 466 | + */ | |
| 467 | + private static function assignable_roles( int $actor ): ?array { | |
| 468 | + if ( $actor < 1 || ! user_can( $actor, 'access_woocommerce_pos' ) || user_can( $actor, 'manage_options' ) ) { | |
| 469 | + return null; | |
| 470 | + } | |
| 471 | + $allowed = self::TILL_ASSIGNABLE_ROLES; | |
| 472 | + if ( user_can( $actor, 'manage_woocommerce' ) ) { | |
| 473 | + $allowed = (array) apply_filters( 'woocommerce_shop_manager_editable_roles', self::TILL_ASSIGNABLE_ROLES ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's own fence list, applied as WooCommerce applies it. | |
| 474 | + } | |
| 475 | + | |
| 476 | + return array_values( array_filter( array_map( 'strval', $allowed ) ) ); | |
| 477 | + } | |
| 478 | + | |
| 479 | + /** | |
| 480 | + * Fence the roles a till user may assign (`editable_roles` filter). | |
| 481 | + * | |
| 482 | + * The Cashier role holds `edit_users` and, on WooCommerce below 9.9, | |
| 483 | + * `promote_users` (customer creation needed it). WordPress's role-update | |
| 484 | + * checks — `WP_REST_Users_Controller::check_role_update()`, `edit_user()` | |
| 485 | + * and the users.php bulk actions — accept those two capabilities and then | |
| 486 | + * ask `get_editable_roles()` which roles the actor may hand out; nothing | |
| 487 | + * ranked them, so a cashier could set an ordinary customer's role to | |
| 488 | + * Administrator. can_modify() does not catch that: it judges the target's | |
| 489 | + * current capabilities, which a plain customer has none of until after the | |
| 490 | + * update. It only ever removes roles; it never adds one. | |
| 491 | + * | |
| 492 | + * @param array $roles Editable roles keyed by role name. | |
| 493 | + * | |
| 494 | + * @return array | |
| 495 | + */ | |
| 496 | + public static function filter_editable_roles( $roles ): array { | |
| 497 | + $roles = (array) $roles; | |
| 498 | + $allowed = self::assignable_roles( get_current_user_id() ); | |
| 499 | + if ( null === $allowed ) { | |
| 500 | + return $roles; | |
| 501 | + } | |
| 502 | + | |
| 503 | + return array_intersect_key( $roles, array_fill_keys( $allowed, true ) ); | |
| 504 | + } | |
| 505 | + | |
| 506 | + /** | |
| 507 | + * Refuse a multisite "add existing user" invite outside the fence (`invite_user` action). | |
| 508 | + * | |
| 509 | + * WordPress's wp-admin/user-new.php stores the requested role in the `new_user_<key>` | |
| 510 | + * option and only reads `get_editable_roles()` for the email's label, so an | |
| 511 | + * invite to an existing network account can carry any role; accepting it | |
| 512 | + * calls `add_user_to_blog()` with that role unchecked. Same fence as | |
| 513 | + * filter_editable_roles(): a fenced actor's invite may name only an | |
| 514 | + * assignable role, or the invite is deleted before its email goes out. | |
| 515 | + * | |
| 516 | + * @param int $user_id Invited user ID. | |
| 517 | + * @param array|null $role Role label array, null when the role was not editable. | |
| 518 | + * @param string $newuser_key Invitation key. | |
| 519 | + * | |
| 520 | + * @return void | |
| 521 | + */ | |
| 522 | + public static function refuse_unfenced_invite( $user_id, $role, $newuser_key ): void { | |
| 523 | + $allowed = self::assignable_roles( get_current_user_id() ); | |
| 524 | + if ( null === $allowed ) { | |
| 525 | + return; | |
| 526 | + } | |
| 527 | + $invite = get_option( 'new_user_' . $newuser_key ); | |
| 528 | + $requested = \is_array( $invite ) && isset( $invite['role'] ) ? (string) $invite['role'] : ''; | |
| 529 | + if ( \in_array( $requested, $allowed, true ) ) { | |
| 530 | + return; | |
| 531 | + } | |
| 532 | + delete_option( 'new_user_' . $newuser_key ); | |
| 533 | + wp_die( | |
| 534 | + esc_html__( 'Sorry, you are not allowed to give users that role.', 'woocommerce-pos' ), | |
| 535 | + '', | |
| 536 | + array( 'response' => 403 ) | |
| 537 | + ); | |
| 345 | 538 | } |
| 346 | 539 | |
| 347 | 540 | /** |
| 348 | 541 | * Build the staff account permission error. |