Store Projects

Project: Customer-Query Handler

⏱ 18 min

This is the last project in the course, and it’s the one where a scoping mistake does the most damage: a support-facing agent that looks up a customer’s order history and answers status questions is, by definition, an ability that touches personally identifiable information. This lesson leans on the permission-scoping depth from Course 4, applied specifically to WooCommerce order data, and treats “which fields does this return, to whom” as the central design question, not an afterthought.

What you'll learn in this lesson
Defining the minimum data actually needed
Order status, items, tracking, not full billing or payment detail.
A two-tier permission model
Customers see only their own orders, staff need a specific capability, no anonymous access ever.
Preventing IDOR: order IDs alone are not authorization
Verifying the requesting identity actually owns (or is staff for) the requested order.
Output-schema minimization and access logging
Returning only what's needed, and recording who looked up what.
Prerequisites

WooCommerce with real customer accounts and order history. Course 4’s permission and capability-scoping lessons, this lesson assumes that depth rather than re-deriving it. No specific WooCommerce version requirement, this lesson uses wc_get_order() directly.

This ability is the highest-risk one in the course

Every other lesson in this course either touches aggregate or catalog data, or gates writes behind confirmation. This one returns individual customers’ order details on request, get the permission model wrong here and you’ve built a data-leak surface, not a support tool. Read Step 2 carefully before adapting this pattern to your own site.

Step 1: Defining the minimum necessary fields

Before writing any code, decide what a status question actually requires: order status, item names and quantities, and shipping/tracking info if you store it. It does not need full billing address, payment method details, or the customer’s email beyond what’s needed to confirm identity. Build the output schema around that answer, not around “whatever WC_Order happens to expose”:

// File: my-plugin.php
function my_plugin_minimal_order_view( $order ) {
	$items = array();
	foreach ( $order->get_items() as $item ) {
		$items[] = array( 'name' => $item->get_name(), 'quantity' => $item->get_quantity() );
	}

	return array(
		'order_id'     => $order->get_id(),
		'status'       => $order->get_status(),
		'date_created' => $order->get_date_created() ? $order->get_date_created()->date( 'Y-m-d' ) : '',
		'items'        => $items,
		'total'        => $order->get_total(),
	);
}

No billing address, no payment method, no card fragments, deliberately.

Step 2: A two-tier permission check, and closing the IDOR gap

The critical mistake to avoid: treating “an order ID was supplied” as authorization to return that order. A customer’s own session must only unlock their own orders, and an order ID passed in by anyone is not, by itself, proof of ownership.

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/lookup-order-status',
		array(
			'label'         => __( 'Look up order status', 'my-plugin' ),
			'description'   => __( 'Returns status, items, and total for one order. Customers may only look up their own orders, support staff require the manage_woocommerce capability.', 'my-plugin' ),
			'category'      => 'store-inventory',
			'input_schema'  => array(
				'type'       => 'object',
				'properties' => array(
					'order_id' => array( 'type' => 'integer' ),
				),
				'required'   => array( 'order_id' ),
			),
			'output_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'order' => array( 'type' => 'object' ),
				),
			),
			'permission_callback' => function ( $input ) {
				if ( ! is_user_logged_in() ) {
					return false;
				}
				if ( current_user_can( 'manage_woocommerce' ) ) {
					return true;
				}
				$order = wc_get_order( $input['order_id'] );
				return $order && (int) $order->get_customer_id() === get_current_user_id();
			},
			'execute_callback' => function ( $input ) {
				$order = wc_get_order( $input['order_id'] );
				if ( ! $order ) {
					return array( 'order' => null );
				}

				my_plugin_log_order_lookup( $input['order_id'], get_current_user_id() );

				return array( 'order' => my_plugin_minimal_order_view( $order ) );
			},
			'meta' => array(
				'annotations' => array( 'readonly' => true ),
				'mcp'         => array( 'public' => false ),
			),
		)
	);
} );

Notice permission_callback here receives the same $input the execute step does, specifically so it can check the requested order’s actual customer ID against the current user, not just check a role in the abstract. This is the fix for the IDOR risk: the check isn’t “is this user logged in,” it’s “does this specific order belong to this specific user, or is this user staff.”

Never expose this ability to anonymous callers

Unlike the shopping-assistant and recommendation abilities earlier in this course, permission_callback here can never safely be __return_true, or even a bare is_user_logged_in() alone. Both order ownership and the staff capability must be checked, together, every single call, regardless of what any calling code assumes.

Step 3: Logging access, for audit

Because this ability returns individual customer data, log every successful lookup, who looked up which order and when, separate from WooCommerce’s own order notes:

// File: my-plugin.php
function my_plugin_log_order_lookup( $order_id, $requesting_user_id ) {
	error_log( sprintf(
		'[order-lookup] user %d viewed order %d at %s',
		$requesting_user_id,
		$order_id,
		current_time( 'mysql' )
	) );
}

Route this to a proper logging destination in production rather than the PHP error log, the point is that every access to this ability leaves a record you can review later.

Test it

wp-env run cli wp eval '
wp_set_current_user( 5 ); // A real customer user ID who owns order 42
$ability = wp_get_ability( "my-plugin/lookup-order-status" );
var_dump( $ability->execute( array( "order_id" => 42 ) ) );
'

Then confirm the negative case, a different customer ID against the same order should be denied:

wp-env run cli wp eval '
wp_set_current_user( 6 ); // A different customer, does not own order 42
$ability = wp_get_ability( "my-plugin/lookup-order-status" );
var_dump( $ability->execute( array( "order_id" => 42 ) ) );
'

The second call must return false from permission_callback and never reach execute_callback at all.

Testing only the happy path

It’s easy to test that a customer can see their own order and stop there. The test that actually matters for this lesson is the negative one: confirming a different customer, or an anonymous request, is refused. If you only ever run the happy-path test, an IDOR bug in the permission check can sit unnoticed indefinitely.

Recap

A customer-support ability that touches order history needs a minimal, deliberately narrow output schema, a two-tier permission check that verifies actual order ownership rather than trusting a supplied order ID, and an access log separate from WooCommerce’s own notes. This closes out the course’s project lessons on the same note Course 4 opened with: permission checks are the security model, and they need to hold up against the specific, real request being made, not just a role check in the abstract.

Resources & further reading

← Project: AI Product Recommendations Finish ✓