PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.24
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.24
1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 All 169 releases
← All changes | includes/Services/Permission_Rules.php +203 -10 1.10.20 → 1.10.24 View file →
@@ -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.