Skip to content

Add core/user-create, core/user-update, and core/user-delete abilities - #1105

Open
jorgefilipecosta wants to merge 65 commits into
developfrom
add/core-user-write-abilities
Open

jorgefilipecosta wants to merge 65 commits into
developfrom
add/core-user-write-abilities

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

What?

Adds core/user-create, core/user-update, and core/user-delete, abilities that create, edit, and delete users.

How?

  • The abilities live in includes/Abilities/Users/Users.php, next to core/users-query, behind the Custom Abilities experiment. The Users_Query gate class is now Users, as it gates all four abilities. Like the read abilities, they are marked public. Only init() and register() are public methods; as in core, the ability callbacks are closures over private methods.
  • core/user-create takes username and email, plus password, name, first_name, last_name, url, description, locale, nickname, slug, and roles. Without a password, one is generated and the user is emailed a link to set their own, as in wp-admin. core/user-update takes an id and the same fields except username. core/user-delete takes an id and reassign: the ID of the user who gets the content, or false to delete it, which sends posts and pages to the trash. Users cannot be trashed, so deleting is permanent.
  • Creating needs create_users, plus promote_users to give the new user roles; updating needs edit_user, or promote_user when only roles is given, and deleting needs delete_user. Changing roles needs promote_user too: an empty list removes every role, users cannot remove their own, and sending back the current roles is no change. Users cannot delete their own account, and users cannot be deleted on multisite.
  • Each ability returns the user with the core/users-query fields, picked with fields; delete returns it as it was before the deletion. The password is never returned.
  • core/users-query follows core's version (Abilities API: Add core/users-query ability wordpress-develop#10775): collections keep the default order, by username, instead of display name, and an include request without per_page returns every included user on one page. Its label is now "Query Users". Its filter errors map the filter to its message under params in the error data, and the user output schemas of all four abilities mark the always-returned id as required.

Testing Instructions

  1. Enable the Custom Abilities experiment.
  2. In the block editor console, run the snippets from this comment.
  3. npm run test:php -- --filter 'UserCreateTest|UserUpdateTest|UserDeleteTest|UsersTest|Gated_AbilitiesTest'
  4. npm run test:e2e -- tests/e2e/specs/abilities/core-user-write.spec.js

Changelog Entry

Added - New core/user-create, core/user-update, and core/user-delete abilities.
Changed - core/users-query orders users by username, as core does, instead of by display name.
Changed - core/users-query returns every included user on one page when per_page is not given, and refuses more than 100 IDs in include.
Changed - The Users_Query gated ability class is now Users, so code that removes it with the wpai_gated_abilities filter needs the new class name.

Open WordPress Playground Preview

@codecov

codecov Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.96285% with 39 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.64%. Comparing base (775f152) to head (29c7a68).
⚠️ Report is 13 commits behind head on develop.

Files with missing lines Patch % Lines
includes/Abilities/Users/Users.php 93.96% 39 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop    #1105      +/-   ##
=============================================
+ Coverage      81.67%   82.64%   +0.97%     
- Complexity      3103     3522     +419     
=============================================
  Files            129      136       +7     
  Lines          12334    13699    +1365     
=============================================
+ Hits           10074    11322    +1248     
- Misses          2260     2377     +117     
Flag Coverage Δ
unit 82.64% <93.96%> (+0.97%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@jorgefilipecosta

jorgefilipecosta commented Oct 2, 2026 •

Copy link
Copy Markdown
Member Author

Tested in the browser console on WordPress 7.1.2, from a post's editor with the Excerpt Generation and Custom Abilities experiments enabled (Excerpt Generation puts @wordpress/abilities and @wordpress/core-abilities in the import map).

await ( await import( '@wordpress/core-abilities' ) ).ready;
const { executeAbility } = await import( '@wordpress/abilities' );

Create a user

const user = await executeAbility( 'core/user-create', {
	username: 'ability_demo',
	email: 'ability_demo@example.com',
	password: 'correct horse battery staple',
	name: 'Ability Demo',
	roles: [ 'author' ],
	fields: [ 'id', 'username', 'name', 'email', 'roles' ],
} );
// { id: 418, name: 'Ability Demo', username: 'ability_demo', email: 'ability_demo@example.com', roles: [ 'author' ] }

Update the user

await executeAbility( 'core/user-update', { id: user.id, first_name: 'Ability', roles: [ 'editor' ], fields: [ 'first_name', 'roles' ] } );
// { id: 418, first_name: 'Ability', roles: [ 'editor' ] }

// An empty list removes every role.
await executeAbility( 'core/user-update', { id: user.id, roles: [], fields: [ 'roles' ] } );
// { id: 418, roles: [] }

// Users cannot remove their own roles.
await executeAbility( 'core/user-update', { id: Number( userSettings.uid ), roles: [] } );
// Rejects with { code: 'users_user_invalid_role', message: 'Sorry, you are not allowed to remove your own roles.', data: { status: 403 } }

Delete the user

// Users cannot be trashed, so this deletes the user permanently and returns it as it was before.
// `reassign: false` deletes the user's content, sending posts and pages to the trash; a user ID gives it to that user.
await executeAbility( 'core/user-delete', { id: user.id, reassign: false, fields: [ 'username' ] } );
// { id: 418, username: 'ability_demo' }

@jeffpaul

jeffpaul commented Oct 4, 2026

Copy link
Copy Markdown
Member

@jorgefilipecosta note that I'm holding off on requesting reviews until this comes out of draft status

@jeffpaul jeffpaul added this to the 1.5.0 milestone Oct 4, 2026
Drops the display name ordering from #948, so `core/users-query` matches
the core port again. Leaving `orderby` at the WP_User_Query default lets
queries over the same users share cached results.
Matches the core port and the REST users schema. `url` stays without it:
it is empty for users without a website, and the abilities JS client,
which re-validates the output with format checks, would reject the empty
string.
Core sets only `public`. WordPress 7.0 ignores it, so the plugin keeps
`show_in_rest` and now marks that difference with a `Plugin:` note.
Trunk's KSES rewrite (r64233) drops the text of a disallowed `<script>`
element, which older versions keep. Take the expected description's
script text from KSES, so the HTML round-trip tests pass on both.
Matches the core class. The `show_in_rest` plugin note fits on one line, so it stays a `// Plugin:` comment.
Mirrors the paragraph added to the core class docblock. It replaces the plugin note about the class being final and instance-based, which the core class now is too.
Without `per_page`, an `include` request used the default page size of
10, so a caller loading a known set of users silently received only the
first 10. Page such a request to the number of included IDs instead, and
cap `include` at the maximum page size so the IDs always fit on one
page. An explicit `per_page` still wins. Ported from
WordPress/wordpress-develop#10775.
The note said that leaving the include order out of `orderby` lets
WP_User_Query share cached results with other queries. It does not:
WP_User_Query keys its cache on the SQL, and `ID IN (...)` lists the IDs
in the order given, so lists in a different order never share an entry
either way. Say what the code does instead: like the REST users
controller, the include list filters the results without ordering them.
Also say so in the `include` description, and fix the test docblock that
repeated the note. Ported from WordPress/wordpress-develop#10775.
WordPress/wordpress-develop#10775 now reads the forms of `true` with
rest_is_boolean() and rest_sanitize_boolean(). For a mixed value,
PHPStan cannot resolve the template type that the WordPress stubs give
rest_sanitize_boolean(), so the plugin keeps listing the same forms by
hand. Mark the difference.
Parse the value with wp_parse_list() and keep only its strings. The loop
and array_unique() that dropped empty and duplicate items are not
needed, because schema validation has already rejected them. Ported from
WordPress/wordpress-develop#10775.
Treat `fields: []` like an omitted `fields` argument and return the
default fields, as normalize_fields() already did, instead of rejecting
the list in the input schema. `core/content-query` accepts an empty list
too, so clients can use one convention for both abilities. The create,
update, and delete abilities share the `fields` schema, so they accept
an empty list too.

Also say in the `fields` descriptions that `id` is always included, and
that fields the current user cannot view are omitted rather than
causing an error. Ported from WordPress/wordpress-develop#10775.
A single-user lookup that the execute callback could not resolve
returned `ability_invalid_permissions` without an HTTP status. Return
`users_not_found` with a 404 instead, like the `content_not_found` error
of `core/content-query`. Gated transports still never reach it, because
the permission callback denies the same lookups first. A user the
current user cannot read is reported like a missing one. The update and
delete abilities give the same error for a user who does not exist or
does not belong to the site, so they return `users_not_found` too.

Report a page past the last one as `users_invalid_page_number` with a
400, like `core/content-query` and the REST posts controller, instead of
returning an empty list. An empty result set still returns zero totals
on any page. Ported from WordPress/wordpress-develop#10775.
On multisite, a super admin can publish posts on a site without being a
member of it. The posts show them as an author on the front end, and
`core/content-query` returns their `author_slug`, but a lookup of that
slug was refused, because every lookup of another user required
membership of the site.

Only require membership for the capability checks, which should reveal
users of the site, not of the whole network. Lookups by ID or slug now
find public authors who are not members, with the fields of a public
author. Lookups by email or username still only find users of the site.
Ported from WordPress/wordpress-develop#10775.
`core/content-query` lets a user who can edit others' posts resolve any
user of the site as an `author_slug`, since they can make any of them
the author of a post. A lookup of that slug here was refused when the
user had no public posts, such as an author whose only post is pending
review.

Let a caller who can edit others' posts of a post type that supports
authors look up any user of the site by ID or slug, and return only the
fields that identify the user: `id`, `name`, `link`, `slug`, and
`avatar_urls`. The profile fields, `description` and `url`, and the
sensitive fields are left out, and their descriptions now say they are
present when the current user can view them. Lookups by email or
username still require permission to list or edit users, and
collections still only list public authors to callers who cannot list
users.

The permission and execute callbacks share the decision through
get_readable_fields(), which replaces can_read_user_for_lookup(), and
find_user() replaces resolve_readable_user(). Ported from
WordPress/wordpress-develop#10775.
@gziolo gziolo changed the title Add core/user-create, core/user-update, and core/user-delete abilities Add core/user-create, core/user-update, and core/user-delete abilities Oct 8, 2026
@jorgefilipecosta
jorgefilipecosta marked this pull request as ready for review October 8, 2026 14:50
@jorgefilipecosta
jorgefilipecosta requested a review from a team October 8, 2026 14:50
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: jorgefilipecosta <jorgefilipecosta@git.wordpress.org>
Co-authored-by: dkotter <dkotter@git.wordpress.org>
Co-authored-by: gziolo <gziolo@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

- Report a page past the last one as not found (404) instead of as a
  caller error.
- Fail closed on collection filters that cannot be honored instead of
  dropping them, which silently widened the query: an `include` list
  with no valid ID, an empty `roles` list, and a `has_published_posts`
  value that is neither true nor a list of post types are rejected, and
  a role filter is refused to a caller who cannot list users.
- Read `id`, `page`, and `per_page` with the content query's integer
  parser, so a fraction or a value beyond 2 ** 53 cannot be cast onto
  another user, and paging that cannot be parsed falls back to the
  defaults.
- Default the input schema to an empty array, like the other core
  abilities, and say in the description that the ability requires an
  authenticated user.
- Tests: cover the rejected filters and the pagination fallback.
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php
Comment thread includes/Abilities/Users/Users.php
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
'type' => 'string',
'description' => __( 'An alphanumeric identifier for the user.', 'ai' ),
),
'roles' => array(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the write schema list the available role names and reject duplicates, like the query schema does? Keep the empty list allowed: WordPress supports users with no roles, so roles: [] removes their roles, while omitting roles leaves them unchanged.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, the write schema now lists the registered roles and rejects duplicates. An empty list is still accepted, since that is how a user's roles get removed.

*
* @return array<string, mixed> The user JSON Schema.
*/
private function get_user_output_schema(): array {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since id is always returned, could we mark it as required in the output schema? The other fields can remain optional.

The same applies to the content output schema in #1025.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, id is now required here, and the content output schemas in #1025 already required it 👍

gziolo
gziolo previously approved these changes Oct 9, 2026

@gziolo gziolo left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My earlier concerns are largely addressed, and this looks good to me. The two remaining schema suggestions are non-blocking from my side: listing valid roles and rejecting duplicates, and marking the returned id as required.

Approving, with the understanding that @dkotter’s outstanding feedback will be addressed before merging.

`core/get-user-info` only returns the current user's own profile, while
`core/users-query` reads any user the current user is allowed to see.
Note this on register_users_query(), so the two abilities are not
mistaken for duplicates. Ported from WordPress/wordpress-develop#10775.
Only init() and register() stay public. The permission and execute
callbacks of `core/users-query`, `core/user-create`, `core/user-update`,
and `core/user-delete` are now private methods, registered through
closures defined in the class, so callers run the abilities through the
Abilities API, such as `wp_get_ability( 'core/users-query' )->execute()`,
which validates the input and checks permissions first.

Tests that call the callbacks directly now capture them from the
registration arguments, through the `wp_register_ability_args` filter.
Ported from WordPress/wordpress-develop#10775.
- Map the filter to the message under `params` in the
  `users_invalid_filter` error data, as the REST API's
  `rest_invalid_param` errors do, so callers can tell which filter
  failed without parsing the translated message.
- Mark `id`, which is always returned, as required in the user output
  schema of the query and the write abilities.
Use list<string> and list<int> for the arrays the users abilities build
as lists, as the plugin does elsewhere, and restore the 1.2.0 @SInCE of
normalize_include(), which the read-users ability already had.
check_reassign() let any numeric value through, which absint() then
turned into another value: -3 or 2.9 gave the posts to user 3 or 2.
get_user() cast the ID with (int), so a permission check, which runs
without input validation, read "2.9" or "2abc" as user 2.

Read both with parse_filter_int(), as the users query does, so such
values are refused. reassign still takes 0, which leaves the posts
without an author, as in the REST API.
On multisite, wpmu_create_user() creates the network user before the
rest of their details are saved and they are added to the site. When
either step failed, the error was returned but the user was left
behind. Delete them with wpmu_delete_user() before returning the error.
wp_insert_user() checks the length of the username, slug and URL, but
not of the email address and display name. When the database refuses a
value too long for one of those columns, wp_update_user() still saves
the user's metadata and reports success, so core/user-update returned
the old value as if the update had worked. The REST users endpoint
behaves the same.

Give `email` and `name` a maxLength of 100 and 250, the lengths of
their columns, so such values are rejected before anything is written.
The create test that relied on the database refusing a long email now
simulates the refusal by dropping the insert query, so it also runs on
databases that do not enforce column lengths.
A slug with no URL-safe characters sanitized to an empty string, which
wp_insert_user() replaced with a slug built from the username, so
updating a user with one revealed their username in their author URL.
Skip such a slug.
Like the users query schema, the create and update schemas now list
the roles registered when the abilities are, and reject a list that
repeats a role. An empty list is still accepted, as it removes every
role. A role removed after registration is still refused when the user
is written.

Duplicates can no longer reach the update, so stop removing them before
comparing the given roles with the current ones.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs review

Development

Successfully merging this pull request may close these issues.

4 participants