Permissions

Building Permission-Gated Tools

⏱ 13 min

A role check answers “can this kind of account do this kind of thing.” It does not answer “should this specific account do this to this specific object, right now.” Most real-world abilities need the second question answered too. This lesson writes permission_callback functions that check ownership, status, and context, on top of the role and capability checks from the previous lesson, not instead of them.

What you'll learn in this lesson
Why a role check alone is often not enough
The gap between "can edit posts" and "can edit this specific post."
Checking object ownership inside permission_callback
Comparing post_author, comment author, or a custom meta field against the current user.
Gating on object state, not just object identity
Allowing an action only when a post, order, or comment is in a specific status.
Combining input_schema arguments with the permission check
permission_callback receives the same arguments execute_callback does.
Prerequisites

Lesson 4 (building a dedicated role). Comfortable reading input_schema and permission_callback from Course 1’s ability lessons.

Step 1: Why role checks alone are frequently not enough

current_user_can( 'edit_posts' ) answers a yes/no question about the account’s general standing, it doesn’t know or care which post is about to be edited. WordPress core handles this for its own meta-capabilities (edit_post, delete_post) by resolving ownership internally, but plenty of ability designs need finer-grained logic than any built-in meta-capability provides, especially once an ability’s argument is something custom, like an order ID, a support ticket ID, or a specific comment.

Step 2: Checking ownership explicitly

permission_callback receives the same request arguments execute_callback does. Use them to look up the actual object and compare it against the current user before granting access:

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/update-own-draft',
		array(
			'label'         => __( 'Update own draft', 'my-plugin' ),
			'input_schema'  => array(
				'type'       => 'object',
				'properties' => array(
					'post_id' => array( 'type' => 'integer' ),
					'content' => array( 'type' => 'string' ),
				),
				'required' => array( 'post_id', 'content' ),
			),
			'permission_callback' => function ( $input ) {
				$post = get_post( $input['post_id'] );

				if ( ! $post ) {
					return false;
				}

				// Role-level check first, object-level check second.
				return current_user_can( 'edit_post', $post->ID )
					&& (int) $post->post_author === get_current_user_id();
			},
			'execute_callback' => function ( $input ) {
				wp_update_post( array(
					'ID'           => $input['post_id'],
					'post_content' => $input['content'],
				) );
				return array( 'updated' => true );
			},
		)
	);
} );

current_user_can( 'edit_post', $post->ID ) already covers most ownership logic through WordPress’s meta-capability mapping, but the explicit post_author comparison makes the intent unambiguous and gives you a place to add further conditions without relying on core’s internal resolution alone.

Always re-fetch the object inside permission_callback

Never trust an ability’s input arguments as already-validated. permission_callback should independently load the real object (get_post(), get_comment(), a custom lookup) and check it directly, rather than assuming the ID passed in refers to something the caller is actually allowed to touch.

Step 3: Gating on object state, not just identity

Some actions should only be available when an object is in a specific status, regardless of who owns it. A “publish” ability, for instance, might only make sense against posts still in draft or pending:

// File: my-plugin.php
'permission_callback' => function ( $input ) {
	$post = get_post( $input['post_id'] );

	if ( ! $post || ! in_array( $post->post_status, array( 'draft', 'pending' ), true ) ) {
		return false;
	}

	return current_user_can( 'publish_posts' );
},

This prevents an agent from re-triggering a publish action on already-published content, or acting on a trashed or scheduled post where that action doesn’t make sense, even if the role-level capability check alone would have allowed it.

Ownership checks
Compare post_author, comment author, or a custom "owner" meta field against get_current_user_id().
Status checks
Only allow the action when the object is in an expected, safe state for that action.
Combine, do not replace, the role check
Object-level checks narrow access further, they are not a substitute for current_user_can().

Step 4: Return WP_Error for clearer failures, not just false

permission_callback can return a WP_Error instead of a bare false, which surfaces a real, specific message back through the MCP Adapter rather than a generic denial:

'permission_callback' => function ( $input ) {
	$post = get_post( $input['post_id'] );

	if ( ! $post ) {
		return new WP_Error( 'not_found', 'No post with that ID exists.', array( 'status' => 404 ) );
	}

	if ( (int) $post->post_author !== get_current_user_id() ) {
		return new WP_Error( 'forbidden', 'This agent account does not own that post.', array( 'status' => 403 ) );
	}

	return true;
},

This is a genuine usability improvement for agent workflows: a model that receives a specific, structured denial reason can explain the failure to the person it’s working for, rather than just reporting an opaque “action failed.”

Test it: confirm cross-account access is actually denied

Using two agent accounts (or one agent account and a manually created draft owned by a different user), confirm the second scenario correctly fails:

wp-env run cli wp eval '
$other_post = wp_insert_post( array( "post_title" => "Not yours", "post_status" => "draft", "post_author" => 1 ) );
echo $other_post . "\n";
'

Then attempt to call update-own-draft from your agent account against that post ID through your actual MCP client. It should be denied, since the account is not the post’s author, even though it holds the edit_posts capability generally.

Testing only the happy path

It’s easy to verify an ability works when everything lines up, right user, right status, right ownership, and never test the denial paths at all. Write at least one deliberate negative test per permission-gated ability: wrong owner, wrong status, missing object. That’s where the actual security value of this lesson lives.

Recap

A role or capability check tells you what kind of thing an account is generally allowed to do. Object ownership and status checks, added directly inside permission_callback, tell you whether this specific action on this specific object, right now, should be allowed. Combine both, re-fetch objects independently rather than trusting input arguments, and return structured WP_Error responses so denials are informative rather than opaque.

Resources & further reading

← Building Role-Based Access Control on Top of permission_callback Protecting Sensitive Data from AI Agents →