Commit 52ea9c71786 for woocommerce

commit 52ea9c71786c264430c36e7ae52974332ea74a5d
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Thu Nov 12 10:58:12 2026 +0100

    Add a customers-query ability (#70790)

    * Add the customers-query ability

    Returns registered customers with order count, total spent and dates.
    Names, usernames, emails, phone numbers and addresses are left out of the
    schema until Core can withhold customer data behind a filter.

    * Let extensions add fields and filters to customers-query

    Registers the customer resource, so extension fields appear on each
    customer and extension filters narrow the query while the extensible
    abilities feature is on.

    * Add changelog entry for the customers-query ability

    * Cover customers-query in the shared ability contract tests

    * Document that customers-query applies customer filters to user meta

    * Log a user that customers-query skips because it does not load

    * Keep customers registered in the same second in a stable page order

    * Check that customers-query refuses logged-out users and subscribers

diff --git a/plugins/woocommerce/changelog/wooai-1510-customers-query b/plugins/woocommerce/changelog/wooai-1510-customers-query
new file mode 100644
index 00000000000..ef419a5c10f
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1510-customers-query
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a customer query ability that gives AI agents order counts and total spend without names, emails or addresses.
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 28d853edacc..72e895ab2a5 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -295,8 +295,9 @@ class ExtensibleAbilities {
 	 * @param array $args {
 	 *     Filter arguments.
 	 *
-	 *     @type string   $resource  Resource whose list ability applies the filters. Core's `products-query` and
-	 *                               `orders-query` apply the filters of `product` and `order`. The list ability
+	 *     @type string   $resource  Resource whose list ability applies the filters. Core's `products-query`,
+	 *                               `orders-query` and `customers-query` apply the filters of `product`, `order`
+	 *                               and `customer`. A `meta_query` for `customer` matches user meta. The list ability
 	 *                               of another resource must call `get_query_args()` and `add_query_filters_schema()` itself.
 	 *     @type string   $namespace Slug of the extension that owns the filter, such as `subscriptions`.
 	 *     @type string   $filter    Filter name inside the namespace, such as `subscribable`.
@@ -413,7 +414,7 @@ class ExtensibleAbilities {
 	/**
 	 * Get the query arguments of the filters in the input of a list ability.
 	 * Each key holds one clause for each filter, so `array_merge_recursive()`
-	 * adds them to WP_Query or wc_get_orders() arguments with AND. `post__in`
+	 * adds them to WP_Query, wc_get_orders() or WP_User_Query arguments with AND. `post__in`
 	 * holds the IDs that every `include_ids` filter returned, without the IDs
 	 * that any `exclude_ids` filter returned, or `0` when none remain, because
 	 * an empty `post__in` does not filter. Without `include_ids`, the excluded
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index 0e7573d7779..e4824d1df26 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\OrderNotesQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderSendEmail;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\CustomersQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrdersQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\PaymentGatewaysQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductAttributeCreate;
@@ -65,6 +66,7 @@ class AbilitiesLoader {
 		ProductUpdate::class,
 		ShippingZonesQuery::class,
 		TaxRatesQuery::class,
+		CustomersQuery::class,
 	);

 	/**
@@ -118,7 +120,7 @@ class AbilitiesLoader {
 	 * @since 11.3.0
 	 */
 	public static function register_core_resources(): void {
-		foreach ( array( 'product', 'order', 'order_item', 'product_term', 'product_review' ) as $resource_name ) {
+		foreach ( array( 'product', 'order', 'order_item', 'product_term', 'product_review', 'customer' ) as $resource_name ) {
 			ExtensibleAbilities::register_resource( $resource_name );
 		}
 	}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/CustomersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/CustomersQuery.php
new file mode 100644
index 00000000000..a250877e3a3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/CustomersQuery.php
@@ -0,0 +1,277 @@
+<?php
+/**
+ * Customers query ability.
+ *
+ * @package woocommerce
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Queries registered customers. The output carries no name, username, email, phone number or address.
+ */
+class CustomersQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+	private const DEFAULT_PER_PAGE = 10;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/customers-query';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Query customers', 'woocommerce' ),
+			'description'         => __( 'Find registered customers by ID or role, with their order count and total spent. Names, usernames, emails, phone numbers and addresses are withheld. Guest customers are not included.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_output_schema(),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_query_customers' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'idempotent'  => true,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check customer read access, with the same capability as the customers REST API.
+	 *
+	 * @since 11.4.0
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 */
+	public static function can_query_customers( $input = null ): bool {
+		return wc_rest_check_user_permissions( 'read' );
+	}
+
+	/**
+	 * Execute the customer query.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param array $input The input parameters.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input = array() ) {
+		$exclude          = $input['exclude'] ?? array();
+		$default_per_page = empty( $input['include'] ) ? self::DEFAULT_PER_PAGE : max( 1, count( array_diff( array_unique( $input['include'] ), $exclude ) ) );
+		$page             = $input['page'] ?? 1;
+		$per_page         = $input['per_page'] ?? $default_per_page;
+		$role             = $input['role'] ?? 'customer';
+		$order            = strtoupper( $input['order'] ?? 'desc' );
+		$args             = array(
+			'number'  => $per_page,
+			'paged'   => $page,
+			// Registration dates have one-second precision, so ID keeps users registered in the same second in a stable order across pages.
+			'orderby' => 'id' === ( $input['orderby'] ?? 'registered_date' ) ? array( 'ID' => $order ) : array(
+				'registered' => $order,
+				'ID'         => $order,
+			),
+			'fields'  => 'ID',
+		);
+		if ( 'all' !== $role ) {
+			$args['role'] = $role;
+		}
+
+		$extension_args = ExtensibleAbilities::get_query_args( 'customer', $input );
+		if ( is_wp_error( $extension_args ) ) {
+			return $extension_args;
+		}
+		if ( isset( $extension_args['meta_query'] ) ) {
+			$args['meta_query'] = $extension_args['meta_query']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+		}
+		$exclude = array_merge( $exclude, $extension_args['post__not_in'] ?? array() );
+		$include = empty( $input['include'] ) ? null : $input['include'];
+		if ( isset( $extension_args['post__in'] ) ) {
+			$include = null === $include ? $extension_args['post__in'] : array_intersect( $include, $extension_args['post__in'] );
+		}
+		if ( null !== $include ) {
+			$include         = array_values( array_diff( $include, $exclude ) );
+			$args['include'] = empty( $include ) ? array( 0 ) : $include;
+		} elseif ( ! empty( $exclude ) ) {
+			$args['exclude'] = array_values( array_unique( $exclude ) );
+		}
+
+		$query    = new \WP_User_Query( $args );
+		$user_ids = array_map( 'intval', $query->get_results() );
+		cache_users( $user_ids );
+
+		$customers = array();
+		foreach ( $user_ids as $user_id ) {
+			try {
+				$customer = new \WC_Customer( $user_id );
+			} catch ( \Exception $e ) {
+				wc_get_logger()->warning(
+					'The customers-query ability skipped a user that does not load as a customer, so total counts more customers than it returns.',
+					array(
+						'source'        => 'woocommerce-abilities',
+						'user_id'       => $user_id,
+						'error_message' => $e->getMessage(),
+					)
+				);
+				continue;
+			}
+			$formatted = self::format_customer( $customer );
+			if ( is_wp_error( $formatted ) ) {
+				return $formatted;
+			}
+			$customers[] = $formatted;
+		}
+
+		$total = (int) $query->get_total();
+		return array(
+			'customers'   => $customers,
+			'total'       => $total,
+			'total_pages' => (int) ceil( $total / $per_page ),
+			'page'        => $page,
+			'per_page'    => $per_page,
+		);
+	}
+
+	/**
+	 * Format a customer. Every field here must stay free of personal data.
+	 *
+	 * @param \WC_Customer $customer Customer.
+	 * @return array|\WP_Error
+	 */
+	private static function format_customer( \WC_Customer $customer ) {
+		$output = array(
+			'id'                 => $customer->get_id(),
+			'role'               => $customer->get_role(),
+			'is_paying_customer' => (bool) $customer->get_is_paying_customer(),
+			'order_count'        => (int) $customer->get_order_count(),
+			'total_spent'        => self::format_amount_for_response( $customer->get_total_spent() ),
+			'date_created'       => self::format_date_for_response( $customer->get_date_created() ),
+			'date_modified'      => self::format_date_for_response( $customer->get_date_modified() ),
+		);
+		return ExtensibleAbilities::add_fields_to_object(
+			array_filter( $output, static fn( $value ) => null !== $value ),
+			'customer',
+			$customer
+		);
+	}
+
+	/**
+	 * Get the ability output schema.
+	 *
+	 * @return array
+	 */
+	private static function get_output_schema(): array {
+		$date   = array(
+			'type'   => 'string',
+			'format' => 'date-time',
+		);
+		$schema = self::get_collection_output_schema(
+			'customers',
+			ExtensibleAbilities::add_fields_schema(
+				array(
+					'type'       => 'object',
+					'properties' => array(
+						'id'                 => array( 'type' => 'integer' ),
+						'role'               => array( 'type' => 'string' ),
+						'is_paying_customer' => array( 'type' => 'boolean' ),
+						'order_count'        => array( 'type' => 'integer' ),
+						'total_spent'        => array(
+							'type'        => 'string',
+							'description' => __( 'Decimal amount in the store currency, from paid orders.', 'woocommerce' ),
+						),
+						'date_created'       => $date,
+						'date_modified'      => $date,
+					),
+				),
+				'customer'
+			)
+		);
+
+		$schema['properties']['total'] = array(
+			'type'        => 'integer',
+			'description' => __( 'Number of customers that match the query.', 'woocommerce' ),
+		);
+		return $schema;
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		$ids    = array(
+			'type'  => 'array',
+			'items' => array(
+				'type'    => 'integer',
+				'minimum' => 1,
+			),
+		);
+		$schema = array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'include'  => $ids + array(
+					'description' => __( 'Return only these customer IDs, up to 100. Without per_page, they come back in one page.', 'woocommerce' ),
+					'maxItems'    => 100,
+				),
+				'exclude'  => $ids + array( 'description' => __( 'Leave out these customer IDs.', 'woocommerce' ) ),
+				'role'     => array(
+					'type'        => 'string',
+					'description' => __( 'User role to list. Use all for every registered user. Defaults to customer.', 'woocommerce' ),
+					'enum'        => array_merge( array( 'all' ), array_keys( wp_roles()->get_names() ) ),
+					'default'     => 'customer',
+				),
+				'orderby'  => array(
+					'type'    => 'string',
+					'enum'    => array( 'registered_date', 'id' ),
+					'default' => 'registered_date',
+				),
+				'order'    => array(
+					'type'    => 'string',
+					'enum'    => array( 'asc', 'desc' ),
+					'default' => 'desc',
+				),
+				'page'     => array(
+					'type'    => 'integer',
+					'default' => 1,
+					'minimum' => 1,
+				),
+				'per_page' => array(
+					'type'        => 'integer',
+					'description' => __( 'Customers per page. Defaults to 10, or to the number of IDs in include.', 'woocommerce' ),
+					'minimum'     => 1,
+					'maximum'     => 100,
+				),
+			),
+			'additionalProperties' => false,
+			'default'              => array(),
+		);
+		return ExtensibleAbilities::add_query_filters_schema( $schema, 'customer' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index ee3bd3cb70f..b33b3352ddb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -47,6 +47,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		'woocommerce/order-add-note',
 		'woocommerce/order-notes-query',
 		'woocommerce/payment-gateways-query',
+		'woocommerce/customers-query',
 	);

 	/**
@@ -335,6 +336,11 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 				'idempotent'  => true,
 				'destructive' => false,
 			),
+			'woocommerce/customers-query'        => array(
+				'readonly'    => true,
+				'idempotent'  => true,
+				'destructive' => false,
+			),
 		);

 		foreach ( $expectations as $ability_id => $annotations ) {
@@ -1021,6 +1027,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 				'woocommerce/payment-gateways-query',
 				array(),
 			),
+			'customers-query'        => array(
+				'woocommerce/customers-query',
+				array(),
+			),
 		);
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/CustomersQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/CustomersQueryTest.php
new file mode 100644
index 00000000000..a1b310e39f2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/CustomersQueryTest.php
@@ -0,0 +1,263 @@
+<?php
+/**
+ * Customers query tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Internal\Abilities\Domain\CustomersQuery;
+
+/**
+ * Tests customer queries through the Abilities API.
+ */
+class CustomersQueryTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var \WP_Ability
+	 */
+	private $sut;
+
+	/**
+	 * Set up an authorized caller.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$this->sut = new \WP_Ability( CustomersQuery::get_name(), CustomersQuery::get_registration_args() );
+	}
+
+	/**
+	 * Create a customer with personal data on every field that holds it.
+	 *
+	 * @param string $role User role.
+	 * @return int
+	 */
+	private function customer( string $role = 'customer' ): int {
+		$id       = self::factory()->user->create(
+			array(
+				'role'       => $role,
+				'user_login' => 'private-login-' . wp_generate_password( 6, false ),
+				'user_email' => 'private-' . wp_generate_password( 6, false ) . '@example.test',
+			)
+		);
+		$customer = new \WC_Customer( $id );
+		$customer->set_first_name( 'Privatefirst' );
+		$customer->set_last_name( 'Privatelast' );
+		$customer->set_billing_address_1( 'Private Street 1' );
+		$customer->set_billing_phone( '555-0100' );
+		$customer->set_billing_email( 'private-billing@example.test' );
+		$customer->set_shipping_address_1( 'Private Street 2' );
+		$customer->save();
+		return $id;
+	}
+
+	/**
+	 * @testdox Should return order count and total spent with no personal data anywhere in the output.
+	 */
+	public function test_returns_spend_without_personal_data(): void {
+		$id    = $this->customer();
+		$order = \WC_Helper_Order::create_order( $id );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		\WC_Helper_Order::create_order( $id );
+
+		$result = $this->sut->execute( array( 'include' => array( $id ) ) );
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 1, $result['total'] );
+		$item = $result['customers'][0];
+		$this->assertSame( $id, $item['id'] );
+		$this->assertSame( 'customer', $item['role'] );
+		$this->assertSame( 2, $item['order_count'], 'Every order counts, paid or not.' );
+		$this->assertSame( wc_format_decimal( $order->get_total(), 2 ), $item['total_spent'], 'Only the paid order adds to the spend.' );
+		$allowed = array( 'id', 'role', 'is_paying_customer', 'order_count', 'total_spent', 'date_created', 'date_modified' );
+		$this->assertSame( array(), array_diff( array_keys( $item ), $allowed ), 'A new customer field must be checked for personal data before it joins the allowlist.' );
+		$json = (string) wp_json_encode( $result );
+		foreach ( array( 'private', 'Private', '555-0100', '@' ) as $needle ) {
+			$this->assertStringNotContainsString( $needle, $json );
+		}
+	}
+
+	/**
+	 * @testdox Should list only customers by default, every user with role all, and count all matches across pages.
+	 */
+	public function test_role_and_paging(): void {
+		$customers = array( $this->customer(), $this->customer(), $this->customer() );
+		$this->customer( 'shop_manager' );
+
+		$result = $this->sut->execute(
+			array(
+				'per_page' => 2,
+				'orderby'  => 'id',
+				'order'    => 'asc',
+			)
+		);
+		$this->assertSame( 3, $result['total'] );
+		$this->assertSame( 2, $result['total_pages'] );
+		$this->assertSame( array_slice( $customers, 0, 2 ), array_column( $result['customers'], 'id' ) );
+
+		$result = $this->sut->execute(
+			array(
+				'per_page' => 2,
+				'page'     => 2,
+				'orderby'  => 'id',
+				'order'    => 'asc',
+			)
+		);
+		$this->assertSame( array( $customers[2] ), array_column( $result['customers'], 'id' ) );
+
+		$this->assertSame( 1, $this->sut->execute( array( 'role' => 'shop_manager' ) )['total'] );
+		$this->assertSame( count_users()['total_users'], $this->sut->execute( array( 'role' => 'all' ) )['total'] );
+	}
+
+	/**
+	 * @testdox Should order customers registered in the same second by ID, so pages neither repeat nor skip them.
+	 * @testWith ["desc"]
+	 *           ["asc"]
+	 * @param string $order Sort direction.
+	 */
+	public function test_same_second_registrations_page_by_id( string $order ): void {
+		$ids = array();
+		for ( $i = 0; $i < 3; $i++ ) {
+			$ids[] = self::factory()->user->create(
+				array(
+					'role'            => 'customer',
+					'user_registered' => '2026-01-01 00:00:00',
+				)
+			);
+		}
+		if ( 'desc' === $order ) {
+			$ids = array_reverse( $ids );
+		}
+
+		$pages = array();
+		for ( $page = 1; $page <= 3; $page++ ) {
+			$result = $this->sut->execute(
+				array(
+					'order'    => $order,
+					'per_page' => 1,
+					'page'     => $page,
+				)
+			);
+			$pages  = array_merge( $pages, array_column( $result['customers'], 'id' ) );
+		}
+		$this->assertSame( $ids, $pages );
+	}
+
+	/**
+	 * @testdox Should return the IDs in include in one page, minus the IDs in exclude.
+	 */
+	public function test_include_and_exclude(): void {
+		$ids = array();
+		for ( $i = 0; $i < 12; $i++ ) {
+			$ids[] = $this->customer();
+		}
+
+		$result = $this->sut->execute( array( 'include' => $ids ) );
+		$this->assertCount( 12, $result['customers'], 'Without per_page, include comes back in one page.' );
+		$this->assertSame( 12, $result['per_page'] );
+
+		$result = $this->sut->execute(
+			array(
+				'include' => array_slice( $ids, 0, 3 ),
+				'exclude' => array( $ids[1] ),
+			)
+		);
+		$this->assertEqualsCanonicalizing( array( $ids[0], $ids[2] ), array_column( $result['customers'], 'id' ) );
+
+		$result = $this->sut->execute( array( 'exclude' => array_slice( $ids, 1 ) ) );
+		$this->assertSame( array( $ids[0] ), array_column( $result['customers'], 'id' ) );
+	}
+
+	/**
+	 * @testdox Should add extension fields and filters only while the extensible abilities feature is on.
+	 */
+	public function test_extension_fields_and_filters(): void {
+		$saved = array();
+		foreach ( array( 'resources', 'fields', 'filters' ) as $name ) {
+			$property = new \ReflectionProperty( ExtensibleAbilities::class, $name );
+			$property->setAccessible( true );
+			$saved[ $name ] = $property->getValue();
+			$property->setValue( null, array() );
+		}
+		try {
+			update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+			$ids      = array( $this->customer(), $this->customer(), $this->customer() );
+			$input    = array( 'include' => $ids );
+			$baseline = CustomersQuery::execute( $input );
+			$schema   = CustomersQuery::get_registration_args()['output_schema'];
+
+			update_option( 'woocommerce_feature_extensible_abilities_enabled', 'yes' );
+			add_action( 'woocommerce_extensible_abilities_resources_init', array( AbilitiesLoader::class, 'register_core_resources' ), 0 );
+			do_action( 'woocommerce_extensible_abilities_resources_init' );
+			add_action(
+				'woocommerce_extensible_abilities_init',
+				static function () use ( $ids ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'      => 'customer',
+							'namespace'     => 'test-ext',
+							'field'         => 'tier',
+							'output_schema' => array( 'type' => 'string' ),
+							'get_callback'  => static fn( \WC_Customer $customer ) => 'tier-' . $customer->get_id(),
+						)
+					);
+					ExtensibleAbilities::register_query_filter(
+						array(
+							'resource'  => 'customer',
+							'namespace' => 'test-ext',
+							'filter'    => 'members',
+							'schema'    => array( 'type' => 'boolean' ),
+							'callback'  => static fn( bool $members ) => array( 'include_ids' => $members ? array_slice( $ids, 1 ) : array( $ids[0] ) ),
+						)
+					);
+				}
+			);
+			do_action( 'woocommerce_extensible_abilities_init' );
+			$ability = new \WP_Ability( CustomersQuery::get_name(), CustomersQuery::get_registration_args() );
+
+			$result = $ability->execute( $input );
+			$this->assertCount( 3, $result['customers'] );
+			foreach ( $result['customers'] as $customer ) {
+				$this->assertSame( 'tier-' . $customer['id'], $customer['extensions']['test-ext']['tier'] );
+			}
+			$this->assertSame( 'string', $ability->get_output_schema()['properties']['customers']['items']['properties']['extensions']['properties']['test-ext']['properties']['tier']['type'] );
+
+			$result = $ability->execute(
+				array(
+					'include' => array( $ids[0], $ids[1] ),
+					'filters' => array( 'extensions' => array( 'test-ext' => array( 'members' => true ) ) ),
+				)
+			);
+			$this->assertSame( array( $ids[1] ), array_column( $result['customers'], 'id' ), 'An extension filter narrows include instead of adding to it.' );
+
+			update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+			$this->assertSame( $baseline, CustomersQuery::execute( $input ) );
+			$this->assertSame( $schema, CustomersQuery::get_registration_args()['output_schema'] );
+		} finally {
+			foreach ( $saved as $name => $value ) {
+				$property = new \ReflectionProperty( ExtensibleAbilities::class, $name );
+				$property->setAccessible( true );
+				$property->setValue( null, $value );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should let shop managers read customers and refuse users who cannot list users.
+	 */
+	public function test_permissions(): void {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+		$this->assertTrue( CustomersQuery::can_query_customers() );
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+		$this->assertWPError( $this->sut->execute() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index f63b4f64157..09e807c6725 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -1113,7 +1113,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 		$resources = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
 		$resources->setAccessible( true );
-		$this->assertSame( array( 'product', 'order', 'order_item', 'product_term', 'product_review' ), array_keys( $resources->getValue() ) );
+		$this->assertSame( array( 'product', 'order', 'order_item', 'product_term', 'product_review', 'customer' ), array_keys( $resources->getValue() ) );
 	}

 	/**