PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.22
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.22
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 1.10.1 1.10.0 1.9.17 All 166 releases
woocommerce-pos / includes / Services / Permission_Rules.php

Permission_Rules.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.22, at includes/Services/Permission_Rules.php

553 lines 20.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS permission rules. WooCommerce remains the authority for its own fences.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 /**
11 * In-process decisions using the canonical Sync\Collections names.
12 */
13 class Permission_Rules {
14 /**
15 * Collection, WooCommerce controller suffix, filter object, POS read grant.
16 */
17 private const RULES = array(
18 'customers' => array( 'Customers', 'user', false ),
19 'orders' => array( 'Orders', 'shop_order', false ),
20 'products' => array( 'Products', 'product', false ),
21 'coupons' => array( 'Coupons', 'shop_coupon', true ),
22 'tax_rates' => array( 'Taxes', 'settings', true ),
23 'tax_classes' => array( 'Tax_Classes', 'settings', true ),
24 'shipping_methods' => array( 'Shipping_Methods', 'shipping_methods', true ),
25 );
26
27 /**
28 * Cashiers have edit_others_shop_orders, but NOT delete_others_shop_orders.
29 */
30 private const ORDER_RULES = array(
31 array(
32 'lane' => 'v1',
33 'context' => 'delete',
34 'ownership' => false,
35 'reason' => 'Cashier lacks delete_others_shop_orders; preserve the legacy flat grant.',
36 ),
37 array(
38 'lane' => 'v2',
39 'context' => 'delete',
40 'ownership' => true,
41 'reason' => 'Cashier lacks delete_others_shop_orders; preserve the current non-owner denial.',
42 ),
43 array(
44 'lane' => 'v1',
45 'context' => 'edit',
46 'ownership' => true,
47 'reason' => 'Cashier holds edit_others_shop_orders; ownership-aware edit is safe on both lanes.',
48 ),
49 array(
50 'lane' => 'v2',
51 'context' => 'edit',
52 'ownership' => true,
53 'reason' => 'Keep the existing ownership-aware edit rule.',
54 ),
55 );
56
57 /**
58 * Nested forwards must restore the enclosing permission scope.
59 *
60 * @var array
61 */
62 private static $scopes = array();
63 /**
64 * Customer re-judge latch, moved from Write_Controller (cff66d7f).
65 *
66 * @var bool
67 */
68 private static $rejudging_user_target = false;
69
70 /**
71 * Return true or WooCommerce's original WP_Error, including credential fences.
72 * Optional plain-data lane and params preserve v1 delete and email/password checks.
73 * Actor 0 means the current user; temporary actor changes are always restored.
74 *
75 * @param string $collection Canonical collection name.
76 * @param string $context Permission context.
77 * @param int $object_id Target object ID.
78 * @param int $actor_id Actor ID, or zero for the current user.
79 * @param string $lane Permission lane.
80 * @param array $params Original request parameters.
81 * @return bool|\WP_Error
82 */
83 public static function verdict( string $collection, string $context, int $object_id = 0, int $actor_id = 0, string $lane = 'v2', array $params = array() ) {
84 $previous = get_current_user_id();
85 $actor_id = $actor_id ? $actor_id : $previous;
86 if ( $actor_id !== $previous ) {
87 wp_set_current_user( $actor_id );
88 }
89 self::install_wc_filter( $collection, $lane );
90 $restore = null;
91 try {
92 if ( 'customers' === $collection && in_array( $context, array( 'edit', 'delete' ), true ) && ! self::can_modify( $actor_id, $object_id ) ) {
93 return self::denial();
94 }
95 if ( 'v1' === $lane && 'customers' === $collection && in_array( $context, array( 'edit', 'delete' ), true ) ) {
96 $restore = self::allow_target_roles( $object_id );
97 }
98 $row = self::RULES[ $collection ] ?? null;
99 if ( null === $row ) {
100 return current_user_can( 'access_woocommerce_pos' ) ? true : new \WP_Error(
101 'woocommerce_rest_cannot_view',
102 __( 'Sorry, you cannot list resources.', 'woocommerce' ),
103 array( 'status' => rest_authorization_required_code() )
104 );
105 }
106 if ( 'v1' === $lane && $row[2] && 'read' === $context && current_user_can( 'access_woocommerce_pos' ) ) {
107 return true; // Legacy overrides bypassed even subsequent WC permission filters.
108 }
109 $class = '\\WC_REST_' . $row[0] . '_Controller';
110 $methods = array(
111 'read' => $object_id ? 'get_item_permissions_check' : 'get_items_permissions_check',
112 'create' => 'create_item_permissions_check',
113 'edit' => 'update_item_permissions_check',
114 'delete' => 'delete_item_permissions_check',
115 'batch' => 'batch_items_permissions_check',
116 );
117 $http_methods = array(
118 'read' => 'GET',
119 'create' => 'POST',
120 'edit' => 'PUT',
121 'delete' => 'DELETE',
122 'batch' => 'POST',
123 );
124 $request = new \WP_REST_Request( $http_methods[ $context ] );
125 $request->set_query_params( $params );
126 $request->set_param( 'id', $object_id );
127 $permission = ( new $class() )->{$methods[ $context ]}( $request );
128 if ( 'v1' === $lane && 'customers' === $collection && 'create' === $context && is_wp_error( $permission ) ) {
129 $cap = version_compare( WC()->version, '9.9', '>=' ) ? 'create_customers' : 'promote_users';
130 if ( current_user_can( $cap ) ) {
131 return true;
132 }
133 }
134 if ( 'v1' === $lane && 'orders' === $collection && is_wp_error( $permission ) && self::wc_filter( false, $context, $object_id, 'shop_order', 'orders', 'v1' ) ) {
135 return true;
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 }
145 return $permission;
146 } finally {
147 if ( $restore ) {
148 $restore();
149 }
150 self::uninstall_wc_filter();
151 if ( $actor_id !== $previous ) {
152 wp_set_current_user( $previous );
153 }
154 }
155 }
156
157 /**
158 * Install only the rules needed by this forward (writes by default).
159 *
160 * @param string $collection Canonical collection name or writes.
161 * @param string $lane Permission lane.
162 * @return void
163 */
164 public static function install_wc_filter( string $collection = 'writes', string $lane = 'v2' ): void {
165 // Removing the callback ends its scopes; reinstallation must not resurrect them.
166 if ( false === has_filter( 'woocommerce_rest_check_permissions', array( self::class, 'wc_filter' ) ) ) {
167 self::$scopes = array();
168 }
169 self::$scopes[] = array( $collection, $lane );
170 add_filter( 'woocommerce_rest_check_permissions', array( self::class, 'wc_filter' ), 10, 4 );
171 }
172
173 /**
174 * Restore the enclosing scope, removing the hook at the outermost boundary.
175 *
176 * @return void
177 */
178 public static function uninstall_wc_filter(): void {
179 array_pop( self::$scopes );
180 if ( empty( self::$scopes ) ) {
181 remove_filter( 'woocommerce_rest_check_permissions', array( self::class, 'wc_filter' ), 10 );
182 }
183 }
184
185 /**
186 * Single WC callback; optional scope arguments serve deprecated public forwarders.
187 *
188 * @param bool $permission Incoming WC permission.
189 * @param string $context Permission context.
190 * @param int $object_id Target object ID.
191 * @param string $post_type WC object type.
192 * @param string|null $collection Explicit collection, or null for the active scope.
193 * @param string $lane Permission lane for an explicit collection.
194 * @return bool
195 */
196 public static function wc_filter( $permission, $context, $object_id, $post_type, $collection = null, $lane = 'v2' ) {
197 if ( null === $collection ) {
198 if ( empty( self::$scopes ) ) {
199 return $permission;
200 }
201 list( $collection, $lane ) = end( self::$scopes );
202 if ( 'v1' === $lane && in_array( $collection, array( 'customers', 'orders' ), true ) ) {
203 return $permission; // V1 judges the complete WC result, not its intermediate bool.
204 }
205 }
206 if ( 'writes' === $collection || 'customers' === $collection ) {
207 if ( 'user' === $post_type && (int) $object_id > 0 && in_array( $context, array( 'edit', 'delete' ), true ) ) {
208 if ( self::$rejudging_user_target ) {
209 return $permission;
210 }
211 if ( ! self::can_modify( get_current_user_id(), (int) $object_id ) ) {
212 return false;
213 }
214 if ( $permission ) {
215 return true;
216 }
217 self::$rejudging_user_target = true;
218 $restore = self::allow_target_roles( (int) $object_id );
219 try {
220 return (bool) wc_rest_check_user_permissions( $context, (int) $object_id );
221 } finally {
222 $restore();
223 self::$rejudging_user_target = false;
224 }
225 }
226 }
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 }
240 // V1 checked existence before its fallback (23defd774); v2 did not.
241 if ( 'v1' === $lane && ( ! wc_get_order( $object_id ) || ! current_user_can( "{$context}_shop_orders" ) ) ) {
242 return $permission;
243 }
244 // Edit is always ownership-aware (ORDER_RULES), so it has no flat fallback.
245 $caps = array(
246 'read' => 'read_private_shop_orders',
247 'create' => 'publish_shop_orders',
248 'delete' => 'delete_shop_orders',
249 );
250 $cap = $caps[ $context ] ?? null;
251 if ( $order instanceof \WC_Abstract_Order ) {
252 $cap = self::owns_order( $order ) ? "{$context}_shop_orders" : "{$context}_others_shop_orders";
253 }
254 if ( $cap && current_user_can( $cap ) ) {
255 $permission = true;
256 }
257 }
258 $row = self::RULES[ $collection ] ?? null;
259 if ( ! $permission && $row && $row[2] && $row[1] === $post_type && 'read' === $context ) {
260 $permission = current_user_can( 'access_woocommerce_pos' );
261 }
262 return $permission;
263 }
264
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 /**
305 * Get the capabilities that identify protected staff accounts.
306 *
307 * @return array
308 */
309 public static function protected_capabilities(): array {
310 /*
311 * Filters the capabilities that mark an account as staff.
312 *
313 * A POS user who cannot manage_options may not edit or delete an account
314 * holding any of these. The default marks site and store administration
315 * (manage_options, manage_woocommerce), user management (edit_users, which
316 * the Cashier role holds) and an author seat in wp-admin (edit_posts:
317 * editors, authors and contributors). Narrowing the list moves a target into
318 * the cleared path. Cleared targets bypass WooCommerce's
319 * woocommerce_shop_manager_editable_roles role-name restriction before
320 * WooCommerce re-judges them.
321 *
322 * @param {array} $capabilities
323 * @returns {array} $capabilities
324 * @since 1.10.10
325 * @hook woocommerce_pos_protected_account_capabilities
326 */
327 $caps = apply_filters( 'woocommerce_pos_protected_account_capabilities', array( 'manage_options', 'manage_woocommerce', 'edit_users', 'edit_posts' ) );
328
329 return array_values( array_unique( array_filter( array_map( 'strval', (array) $caps ) ) ) );
330 }
331
332 /**
333 * Check staff protection before WooCommerce's own permission checks.
334 *
335 * Deny is the default for anything this method cannot resolve: it only ever
336 * removes permission, so a caller that reaches it with a bad id is refused
337 * rather than waved through.
338 *
339 * @param int $actor_id Acting user ID.
340 * @param int $target_id Target user ID.
341 *
342 * @return bool
343 */
344 public static function can_modify( int $actor_id, int $target_id ): bool {
345 if ( $actor_id < 1 || $target_id < 1 ) {
346 return false;
347 }
348 if ( user_can( $actor_id, 'manage_options' ) || $actor_id === $target_id ) {
349 return true;
350 }
351 $target = get_user_by( 'id', $target_id );
352 if ( ! $target ) {
353 return false;
354 }
355 foreach ( self::protected_capabilities() as $cap ) {
356 if ( user_can( $target, $cap ) ) {
357 return false;
358 }
359 }
360
361 return true;
362 }
363
364 /**
365 * Let WooCommerce judge a cleared target by capability rather than role name.
366 *
367 * WooCommerce restricts a shop_manager to editing users whose role is in
368 * `woocommerce_shop_manager_editable_roles` (default: customer only), so a
369 * subscriber or a membership plugin's own customer role is refused outright
370 * even though the POS lists it. Once can_modify() has cleared the target of
371 * every staff capability, that role-name test adds nothing, so allow the
372 * target's own roles for the duration of the check.
373 *
374 * Call only after can_modify() returned true, and always invoke the returned
375 * closure to remove the filter.
376 *
377 * @param int $target_id Target user ID, already cleared by can_modify().
378 *
379 * @return callable Restores the unfiltered behaviour.
380 */
381 public static function allow_target_roles( int $target_id ): callable {
382 $target = get_user_by( 'id', $target_id );
383 $roles = $target ? array_values( (array) $target->roles ) : array();
384
385 if ( empty( $roles ) ) {
386 return static function (): void {};
387 }
388
389 $filter = static function ( $allowed ) use ( $roles ) {
390 return array_values( array_unique( array_merge( (array) $allowed, $roles ) ) );
391 };
392
393 add_filter( 'woocommerce_shop_manager_editable_roles', $filter );
394
395 return static function () use ( $filter ): void {
396 remove_filter( 'woocommerce_shop_manager_editable_roles', $filter );
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 );
538 }
539
540 /**
541 * Build the staff account permission error.
542 *
543 * @return \WP_Error
544 */
545 public static function denial(): \WP_Error {
546 return new \WP_Error(
547 'woocommerce_pos_rest_cannot_edit_staff_account',
548 __( 'Only an administrator can edit or delete a staff account from the POS.', 'woocommerce-pos' ),
549 array( 'status' => rest_authorization_required_code() )
550 );
551 }
552 }
553