| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\API\V2; |
| 9 | 9 | |
| 10 | 10 | use WCPOS\WooCommercePOS\API\V2\Writers\Collection_Writer_Resolver; |
| 11 | +use WCPOS\WooCommercePOS\Services\Customer_Account_Guard; | |
| 11 | 12 | use WCPOS\WooCommercePOS\Services\Tax_Id_Types; |
| 12 | 13 | use WCPOS\WooCommercePOS\Sync\Api; |
| 13 | 14 | use WCPOS\WooCommercePOS\Sync\Collections; |
| 14 | 15 | use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| @@ -43,8 +44,15 @@ | ||
| 43 | 44 | * live in an injected mutation store, so the apply logic is unit-testable with a |
| 44 | 45 | * fake store + a stubbed `rest_do_request`. |
| 45 | 46 | */ |
| 46 | 47 | class Write_Controller extends WP_REST_Controller { |
| 48 | + /** | |
| 49 | + * True while wc_rest_check_user_permissions() is re-run for a cleared target. | |
| 50 | + * | |
| 51 | + * @var bool | |
| 52 | + */ | |
| 53 | + private $rejudging_user_target = false; | |
| 54 | + | |
| 47 | 55 | // Our gate (capability + F13 health); forwarded writes scope the client-tier grant below. |
| 48 | 56 | use Endpoint_Permissions; |
| 49 | 57 | |
| 50 | 58 | |
| @@ -433,58 +441,8 @@ | ||
| 433 | 441 | return $this->envelope_document( $this->document_for( $meta, $new_id ), $m['recordId'], $meta, $new_id, $response->get_status(), $writer ); |
| 434 | 442 | } |
| 435 | 443 | |
| 436 | 444 | /** |
| 437 | - * The grace comparer (#423 step 2, option `woo_rxdb_sync_legacy_revision_grace`, | |
| 438 | - * default on until retirement): on a canonical mismatch, accept a baseRevision | |
| 439 | - * that matches the CURRENT document under a PRE-CUTOVER form — | |
| 440 | - * - the legacy order sha256 (no ksort, volatiles included): same content, | |
| 441 | - * old algorithm ⇒ the precondition is genuinely current; | |
| 442 | - * - a pre-taxonomy-sort sha256 for non-orders: same content, previous | |
| 443 | - * canonicalizer ⇒ queued writes survive the revision transition; | |
| 444 | - * - a pre-1b lane synthesis (non-sha256 values the client fetchers stored | |
| 445 | - * as sync.revision before the proxy revision stamp): date_modified_gmt | |
| 446 | - * for order/post/user collections, String(id) for term collections | |
| 447 | - * (deliberately vacuous — that lane never had optimistic concurrency; | |
| 448 | - * grace honours the contract the record was written under). | |
| 449 | - * A real conflict mismatches every form → 409 exactly as before. The ack | |
| 450 | - * always returns the CANONICAL currentRevision, re-anchoring the client. | |
| 451 | - */ | |
| 452 | - private function revision_matches_with_grace( $base, string $current_revision, array $meta, int $id, array $bare, bool $allow_term_grace = true ): bool { | |
| 453 | - if ( $base === $current_revision ) { | |
| 454 | - return true; | |
| 455 | - } | |
| 456 | - if ( ! is_string( $base ) || 'yes' !== get_option( 'woocommerce_pos_sync_legacy_revision_grace', 'yes' ) ) { | |
| 457 | - return false; | |
| 458 | - } | |
| 459 | - if ( 0 === strpos( $base, 'sha256:' ) ) { | |
| 460 | - if ( 'order' === ( $meta['id_type'] ?? '' ) && $id > 0 ) { | |
| 461 | - if ( Order_Serializer::pre_augmentation_canonical_revision( $bare ) === $base | |
| 462 | - || Order_Serializer::pre_item_uuid_canonical_revision( $bare ) === $base ) { | |
| 463 | - return true; | |
| 464 | - } | |
| 465 | - $payload = ( new Order_Serializer() )->serialize_order( $id, new WP_REST_Request() ); | |
| 466 | - return Order_Serializer::legacy_revision( $payload ) === $base; | |
| 467 | - } | |
| 468 | - return Revision::pre_taxonomy_sort_revision( $bare ) === $base; | |
| 469 | - } | |
| 470 | - $id_type = $meta['id_type'] ?? ''; | |
| 471 | - if ( in_array( $id_type, array( 'order', 'post', 'user' ), true ) ) { | |
| 472 | - // Read the date from wherever the document carries it. A variation's document is the | |
| 473 | - // `{ id, parent_id, payload }` wrapper, so a top-level-only read makes this branch dead | |
| 474 | - // code for the one collection whose canonical revision IS a date — and it would come | |
| 475 | - // back to life, silently, the day the wrapper is dropped. | |
| 476 | - $nested = isset( $bare['payload'] ) && is_array( $bare['payload'] ) ? $bare['payload'] : array(); | |
| 477 | - $date = (string) ( $bare['date_modified_gmt'] ?? $nested['date_modified_gmt'] ?? '' ); | |
| 478 | - return '' !== $date && $base === $date; | |
| 479 | - } | |
| 480 | - if ( 'term' === $id_type && $allow_term_grace ) { | |
| 481 | - return (string) ( $bare['id'] ?? '' ) === $base; | |
| 482 | - } | |
| 483 | - return false; | |
| 484 | - } | |
| 485 | - | |
| 486 | - /** | |
| 487 | 445 | * The pre-CAS post-type capability gate shared by the update and delete paths. |
| 488 | 446 | * |
| 489 | 447 | * Only the WP-post-backed collections carry a Woo capability check of their own; |
| 490 | 448 | * every other collection is gated by the endpoint permission callback alone, so |
| @@ -542,9 +500,9 @@ | ||
| 542 | 500 | return $current; |
| 543 | 501 | } |
| 544 | 502 | $current_bare = is_array( $current->get_data() ) ? $current->get_data() : array(); |
| 545 | 503 | $current_revision = $this->revision_for( $meta, $id, $current_bare ); |
| 546 | - if ( ! $this->revision_matches_with_grace( $m['baseRevision'], $current_revision, $meta, $id, $current_bare ) ) { | |
| 504 | + if ( $m['baseRevision'] !== $current_revision ) { | |
| 547 | 505 | return new WP_REST_Response( |
| 548 | 506 | array( |
| 549 | 507 | 'code' => 'woo_rxdb_sync_conflict', |
| 550 | 508 | 'message' => 'baseRevision is stale.', |
| @@ -607,9 +565,9 @@ | ||
| 607 | 565 | return $current; |
| 608 | 566 | } |
| 609 | 567 | $current_bare = is_array( $current->get_data() ) ? $current->get_data() : array(); |
| 610 | 568 | $current_revision = $this->revision_for( $meta, $id, $current_bare ); |
| 611 | - if ( ! $this->revision_matches_with_grace( $m['baseRevision'], $current_revision, $meta, $id, $current_bare, false ) ) { | |
| 569 | + if ( $m['baseRevision'] !== $current_revision ) { | |
| 612 | 570 | return new WP_REST_Response( |
| 613 | 571 | array( |
| 614 | 572 | 'code' => 'woo_rxdb_sync_conflict', |
| 615 | 573 | 'message' => 'baseRevision is stale.', |
| @@ -809,10 +767,43 @@ | ||
| 809 | 767 | } |
| 810 | 768 | } |
| 811 | 769 | |
| 812 | 770 | /** |
| 813 | - * Authorize proxied catalog mutations for POS users. | |
| 771 | + * Judge a customer edit or delete the way V1\Customers_Controller does. | |
| 814 | 772 | * |
| 773 | + * The staff guard runs first and is final. A target it has cleared is then | |
| 774 | + * re-judged by WooCommerce with the target's own roles allowed through the | |
| 775 | + * shop_manager role-name restriction, so a shop manager can edit a subscriber | |
| 776 | + * from a current app exactly as from the legacy route. WooCommerce's | |
| 777 | + * credential fence is untouched: it runs in the controller, not here. | |
| 778 | + * | |
| 779 | + * @param bool $permission WooCommerce's verdict so far. | |
| 780 | + * @param string $context 'edit' or 'delete'. | |
| 781 | + * @param int $target_id Target user ID. | |
| 782 | + */ | |
| 783 | + private function check_user_permission( bool $permission, string $context, int $target_id ): bool { | |
| 784 | + if ( $this->rejudging_user_target ) { | |
| 785 | + return $permission; | |
| 786 | + } | |
| 787 | + if ( ! Customer_Account_Guard::can_modify( get_current_user_id(), $target_id ) ) { | |
| 788 | + return false; | |
| 789 | + } | |
| 790 | + if ( $permission ) { | |
| 791 | + return true; | |
| 792 | + } | |
| 793 | + $this->rejudging_user_target = true; | |
| 794 | + $restore = Customer_Account_Guard::allow_target_roles( $target_id ); | |
| 795 | + try { | |
| 796 | + return (bool) wc_rest_check_user_permissions( $context, $target_id ); | |
| 797 | + } finally { | |
| 798 | + $restore(); | |
| 799 | + $this->rejudging_user_target = false; | |
| 800 | + } | |
| 801 | + } | |
| 802 | + | |
| 803 | + /** | |
| 804 | + * Authorize proxied mutations for POS users while protecting staff accounts. | |
| 805 | + * | |
| 815 | 806 | * This filter is attached only while a sync push is forwarded to wc/v3, so |
| 816 | 807 | * direct WooCommerce requests keep their normal permission checks. |
| 817 | 808 | * |
| 818 | 809 | * @param bool $permission The current permission. |
| @@ -822,8 +813,14 @@ | ||
| 822 | 813 | * |
| 823 | 814 | * @return bool |
| 824 | 815 | */ |
| 825 | 816 | public function wcpos_check_permissions( $permission, $context, $object_id, $post_type ) { |
| 817 | + // Customer edits/deletes: the staff guard is final, then a cleared target | |
| 818 | + // is judged by WooCommerce the same way the v1 controller judges it. | |
| 819 | + if ( 'user' === $post_type && (int) $object_id > 0 && \in_array( $context, array( 'edit', 'delete' ), true ) ) { | |
| 820 | + return $this->check_user_permission( (bool) $permission, $context, (int) $object_id ); | |
| 821 | + } | |
| 822 | + | |
| 826 | 823 | // Catalog and coupon WRITES require the user's real WooCommerce |
| 827 | 824 | // capabilities — no POS-tier widening. The cashier role is deliberately |
| 828 | 825 | // read-only on catalog (Activator), and a blanket grant here handed |
| 829 | 826 | // every POS user product deletion and coupon minting. Product decision |
| @@ -967,12 +964,11 @@ | ||
| 967 | 964 | * |
| 968 | 965 | * This used to read `$bare['payload']['date_modified_gmt']` only, with `$bare['id']` as |
| 969 | 966 | * the fallback. Against a FLAT document that silently degrades to the variation's own |
| 970 | 967 | * ID — a value that never changes again. The failure would be total and invisible: |
| 971 | - * `revision_matches_with_grace()` would still let a queued date-based write through | |
| 972 | - * (with the wrapper gone, its top-level `date_modified_gmt` branch finally resolves), | |
| 973 | 968 | * the ack would hand the client the id as `currentRevision`, and from then on every |
| 974 | - * stale baseRevision would equal every recomputed one. Two tills editing the same | |
| 969 | + * stale baseRevision would equal every recomputed one — the strict revision | |
| 970 | + * comparison would pass every queued write. Two tills editing the same | |
| 975 | 971 | * variation hours apart would both pass the precondition; the per-record lock would |
| 976 | 972 | * serialize them, so there would be no error — just a lost update, every time. |
| 977 | 973 | * |
| 978 | 974 | * The `$bare['id']` fallback is kept ONLY for a document carrying no date at all, and is |