PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Services / Permission_Rules.php

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

360 lines 12.8 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 return $permission;
138 } finally {
139 if ( $restore ) {
140 $restore();
141 }
142 self::uninstall_wc_filter();
143 if ( $actor_id !== $previous ) {
144 wp_set_current_user( $previous );
145 }
146 }
147 }
148
149 /**
150 * Install only the rules needed by this forward (writes by default).
151 *
152 * @param string $collection Canonical collection name or writes.
153 * @param string $lane Permission lane.
154 * @return void
155 */
156 public static function install_wc_filter( string $collection = 'writes', string $lane = 'v2' ): void {
157 // Removing the callback ends its scopes; reinstallation must not resurrect them.
158 if ( false === has_filter( 'woocommerce_rest_check_permissions', array( self::class, 'wc_filter' ) ) ) {
159 self::$scopes = array();
160 }
161 self::$scopes[] = array( $collection, $lane );
162 add_filter( 'woocommerce_rest_check_permissions', array( self::class, 'wc_filter' ), 10, 4 );
163 }
164
165 /**
166 * Restore the enclosing scope, removing the hook at the outermost boundary.
167 *
168 * @return void
169 */
170 public static function uninstall_wc_filter(): void {
171 array_pop( self::$scopes );
172 if ( empty( self::$scopes ) ) {
173 remove_filter( 'woocommerce_rest_check_permissions', array( self::class, 'wc_filter' ), 10 );
174 }
175 }
176
177 /**
178 * Single WC callback; optional scope arguments serve deprecated public forwarders.
179 *
180 * @param bool $permission Incoming WC permission.
181 * @param string $context Permission context.
182 * @param int $object_id Target object ID.
183 * @param string $post_type WC object type.
184 * @param string|null $collection Explicit collection, or null for the active scope.
185 * @param string $lane Permission lane for an explicit collection.
186 * @return bool
187 */
188 public static function wc_filter( $permission, $context, $object_id, $post_type, $collection = null, $lane = 'v2' ) {
189 if ( null === $collection ) {
190 if ( empty( self::$scopes ) ) {
191 return $permission;
192 }
193 list( $collection, $lane ) = end( self::$scopes );
194 if ( 'v1' === $lane && in_array( $collection, array( 'customers', 'orders' ), true ) ) {
195 return $permission; // V1 judges the complete WC result, not its intermediate bool.
196 }
197 }
198 if ( 'writes' === $collection || 'customers' === $collection ) {
199 if ( 'user' === $post_type && (int) $object_id > 0 && in_array( $context, array( 'edit', 'delete' ), true ) ) {
200 if ( self::$rejudging_user_target ) {
201 return $permission;
202 }
203 if ( ! self::can_modify( get_current_user_id(), (int) $object_id ) ) {
204 return false;
205 }
206 if ( $permission ) {
207 return true;
208 }
209 self::$rejudging_user_target = true;
210 $restore = self::allow_target_roles( (int) $object_id );
211 try {
212 return (bool) wc_rest_check_user_permissions( $context, (int) $object_id );
213 } finally {
214 $restore();
215 self::$rejudging_user_target = false;
216 }
217 }
218 }
219 if ( ! $permission && 'shop_order' === $post_type && in_array( $collection, array( 'writes', 'orders' ), true ) ) {
220 // V1 checked existence before its fallback (23defd774); v2 did not.
221 if ( 'v1' === $lane && ( ! wc_get_order( $object_id ) || ! current_user_can( "{$context}_shop_orders" ) ) ) {
222 return $permission;
223 }
224 // Without a post row, only v1 historically granted the flat edit cap.
225 $caps = array(
226 'read' => 'read_private_shop_orders',
227 'create' => 'publish_shop_orders',
228 'edit' => 'v1' === $lane ? 'edit_shop_orders' : null,
229 'delete' => 'delete_shop_orders',
230 );
231 $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 }
239 }
240 if ( $cap && current_user_can( $cap ) ) {
241 $permission = true;
242 }
243 }
244 $row = self::RULES[ $collection ] ?? null;
245 if ( ! $permission && $row && $row[2] && $row[1] === $post_type && 'read' === $context ) {
246 $permission = current_user_can( 'access_woocommerce_pos' );
247 }
248 return $permission;
249 }
250
251 /**
252 * Get the capabilities that identify protected staff accounts.
253 *
254 * @return array
255 */
256 public static function protected_capabilities(): array {
257 /*
258 * Filters the capabilities that mark an account as staff.
259 *
260 * A POS user who cannot manage_options may not edit or delete an account
261 * holding any of these. The default marks site and store administration
262 * (manage_options, manage_woocommerce), user management (edit_users, which
263 * the Cashier role holds) and an author seat in wp-admin (edit_posts:
264 * editors, authors and contributors). Narrowing the list moves a target into
265 * the cleared path. Cleared targets bypass WooCommerce's
266 * woocommerce_shop_manager_editable_roles role-name restriction before
267 * WooCommerce re-judges them.
268 *
269 * @param {array} $capabilities
270 * @returns {array} $capabilities
271 * @since 1.10.10
272 * @hook woocommerce_pos_protected_account_capabilities
273 */
274 $caps = apply_filters( 'woocommerce_pos_protected_account_capabilities', array( 'manage_options', 'manage_woocommerce', 'edit_users', 'edit_posts' ) );
275
276 return array_values( array_unique( array_filter( array_map( 'strval', (array) $caps ) ) ) );
277 }
278
279 /**
280 * Check staff protection before WooCommerce's own permission checks.
281 *
282 * Deny is the default for anything this method cannot resolve: it only ever
283 * removes permission, so a caller that reaches it with a bad id is refused
284 * rather than waved through.
285 *
286 * @param int $actor_id Acting user ID.
287 * @param int $target_id Target user ID.
288 *
289 * @return bool
290 */
291 public static function can_modify( int $actor_id, int $target_id ): bool {
292 if ( $actor_id < 1 || $target_id < 1 ) {
293 return false;
294 }
295 if ( user_can( $actor_id, 'manage_options' ) || $actor_id === $target_id ) {
296 return true;
297 }
298 $target = get_user_by( 'id', $target_id );
299 if ( ! $target ) {
300 return false;
301 }
302 foreach ( self::protected_capabilities() as $cap ) {
303 if ( user_can( $target, $cap ) ) {
304 return false;
305 }
306 }
307
308 return true;
309 }
310
311 /**
312 * Let WooCommerce judge a cleared target by capability rather than role name.
313 *
314 * WooCommerce restricts a shop_manager to editing users whose role is in
315 * `woocommerce_shop_manager_editable_roles` (default: customer only), so a
316 * subscriber or a membership plugin's own customer role is refused outright
317 * even though the POS lists it. Once can_modify() has cleared the target of
318 * every staff capability, that role-name test adds nothing, so allow the
319 * target's own roles for the duration of the check.
320 *
321 * Call only after can_modify() returned true, and always invoke the returned
322 * closure to remove the filter.
323 *
324 * @param int $target_id Target user ID, already cleared by can_modify().
325 *
326 * @return callable Restores the unfiltered behaviour.
327 */
328 public static function allow_target_roles( int $target_id ): callable {
329 $target = get_user_by( 'id', $target_id );
330 $roles = $target ? array_values( (array) $target->roles ) : array();
331
332 if ( empty( $roles ) ) {
333 return static function (): void {};
334 }
335
336 $filter = static function ( $allowed ) use ( $roles ) {
337 return array_values( array_unique( array_merge( (array) $allowed, $roles ) ) );
338 };
339
340 add_filter( 'woocommerce_shop_manager_editable_roles', $filter );
341
342 return static function () use ( $filter ): void {
343 remove_filter( 'woocommerce_shop_manager_editable_roles', $filter );
344 };
345 }
346
347 /**
348 * Build the staff account permission error.
349 *
350 * @return \WP_Error
351 */
352 public static function denial(): \WP_Error {
353 return new \WP_Error(
354 'woocommerce_pos_rest_cannot_edit_staff_account',
355 __( 'Only an administrator can edit or delete a staff account from the POS.', 'woocommerce-pos' ),
356 array( 'status' => rest_authorization_required_code() )
357 );
358 }
359 }
360