Repository navigation
Add core/user-create, core/user-update, and core/user-delete abilities - #1105
jorgefilipecosta wants to merge 65 commits into
Conversation
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
|
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 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' } |
|
@jorgefilipecosta note that I'm holding off on requesting reviews until this comes out of draft status |
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.
core/user-create, core/user-update, and core/user-delete abilities
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. 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.
| 'type' => 'string', | ||
| 'description' => __( 'An alphanumeric identifier for the user.', 'ai' ), | ||
| ), | ||
| 'roles' => array( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Good idea, id is now required here, and the content output schemas in #1025 already required it 👍
There was a problem hiding this comment.
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.
What?
Adds
core/user-create,core/user-update, andcore/user-delete, abilities that create, edit, and delete users.How?
includes/Abilities/Users/Users.php, next tocore/users-query, behind the Custom Abilities experiment. TheUsers_Querygate class is nowUsers, as it gates all four abilities. Like the read abilities, they are markedpublic. Onlyinit()andregister()are public methods; as in core, the ability callbacks are closures over private methods.core/user-createtakesusernameandemail, pluspassword,name,first_name,last_name,url,description,locale,nickname,slug, androles. Without apassword, one is generated and the user is emailed a link to set their own, as in wp-admin.core/user-updatetakes anidand the same fields exceptusername.core/user-deletetakes anidandreassign: the ID of the user who gets the content, orfalseto delete it, which sends posts and pages to the trash. Users cannot be trashed, so deleting is permanent.create_users, pluspromote_usersto give the new user roles; updating needsedit_user, orpromote_userwhen onlyrolesis given, and deleting needsdelete_user. Changingrolesneedspromote_usertoo: 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.core/users-queryfields, picked withfields; delete returns it as it was before the deletion. The password is never returned.core/users-queryfollows core's version (Abilities API: Addcore/users-queryability wordpress-develop#10775): collections keep the default order, by username, instead of display name, and anincluderequest withoutper_pagereturns every included user on one page. Its label is now "Query Users". Its filter errors map the filter to its message underparamsin the error data, and the user output schemas of all four abilities mark the always-returnedidas required.Testing Instructions
npm run test:php -- --filter 'UserCreateTest|UserUpdateTest|UserDeleteTest|UsersTest|Gated_AbilitiesTest'npm run test:e2e -- tests/e2e/specs/abilities/core-user-write.spec.jsChangelog Entry