Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

New Ability: core/read-users#774

Merged
dkotter merged 43 commits into
developWordPress/ai:developfrom
codex/core-users-abilityWordPress/ai:codex/core-users-abilityCopy head branch name to clipboard
Jul 9, 2026
Merged

New Ability: core/read-users#774
dkotter merged 43 commits into
developWordPress/ai:developfrom
codex/core-users-abilityWordPress/ai:codex/core-users-abilityCopy head branch name to clipboard

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Jun 25, 2026

Copy link
Copy Markdown
Member

What?

  • Adds a read-only core/read-users ability with single-user lookup by id, user_email, user_login, or user_nicename.
  • Single-user lookup modes return the user object directly; collection mode returns users, total, and total_pages.
  • Adds collection mode with optional roles, has_published_posts, include, page, per_page, and fields; omitted REST input defaults to empty collection mode.
  • Uses lean default fields when fields is omitted, while keeping sensitive identifiers and fields opt-in and permission-gated.
  • Uses WP_User-shaped field names, including user_login, user_email, user_nicename, user_url, and user_registered, to align with Core’s existing core/get-user-info ability.
  • Gates sensitive identifiers and fields against Core REST users behavior, omits inaccessible fields per user, excludes capabilities/meta, and respects get_option( 'show_avatars' ).
  • Adds PHPUnit and browser-level Abilities API coverage.

Testing

  • php -l includes/Abilities/Users/Users.php
  • php -l tests/Integration/Includes/Abilities/Users/UsersTest.php
  • vendor/bin/phpcs --standard=phpcs.xml.dist
  • vendor/bin/phpstan analyse --memory-limit=1G
  • npx wp-scripts lint-js tests/e2e/specs/abilities/core-read-users.spec.js
  • npm run lint:js
  • npm run typecheck
  • npm run build
  • npm run test:php -- --filter UsersTest
  • npm run test:php
  • npm run test:e2e -- tests/e2e/specs/abilities/core-read-users.spec.js --project=chromium

Changelog entry

Added - New core/read-users Ability; retrieves a single user by ID, email, login, or nicename, or a filtered and paginated users collection, with sensitive fields opt-in and permission-gated.

Open WordPress Playground Preview

@github-actions

github-actions Bot commented Jun 25, 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: gziolo <gziolo@git.wordpress.org>
Co-authored-by: peterwilsoncc <peterwilsoncc@git.wordpress.org>
Co-authored-by: dkotter <dkotter@git.wordpress.org>

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

@github-actions

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

@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.64286% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.67%. Comparing base (9db55ee) to head (4bb161e).
⚠️ Report is 11 commits behind head on develop.

Files with missing lines Patch % Lines
includes/Abilities/Users/Users.php 94.63% 24 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #774      +/-   ##
=============================================
+ Coverage      75.50%   76.67%   +1.17%     
- Complexity      2086     2198     +112     
=============================================
  Files             99      100       +1     
  Lines           8626     9078     +452     
=============================================
+ Hits            6513     6961     +448     
- Misses          2113     2117       +4     
Flag Coverage Δ
unit 76.67% <94.64%> (+1.17%) ⬆️

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.

@jeffpaul jeffpaul added this to the 1.2.0 milestone Jun 26, 2026
@jeffpaul jeffpaul moved this from Triage to Needs review in WordPress AI Roadmap Jun 26, 2026
@jorgefilipecosta jorgefilipecosta changed the title Add core/users ability Add core/read-users ability Jun 29, 2026
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php
@gziolo

gziolo commented Jun 30, 2026

Copy link
Copy Markdown
Member

@jorgefilipecosta, great work so far. I left my initial feedback to evaluate. The logic is quite complex, or rather sensitive, as it has several security concerns that we need to ensure are addressed correctly from the start.

@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.

A few more points from my read, on top of the inline notes above. They are about behavior and one small docs fix, so none of them block the PR.

Comment thread includes/Abilities/Users/Users.php Outdated
Comment thread includes/Abilities/Users/Users.php
Comment thread includes/Abilities/Users/Users.php Outdated
@gziolo

gziolo commented Jun 30, 2026

Copy link
Copy Markdown
Member

I added three more comments discovered with the help of Claude Code review.

Comment thread includes/Abilities/Users/Users.php Outdated
@gziolo
gziolo requested a review from a team July 3, 2026 08:07
@gziolo
gziolo force-pushed the codex/core-users-ability branch from 989b58f to 7132486 Compare July 3, 2026 08:17
@dkotter

dkotter commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Thank you a lot for the review and applying some suggestions @gziolo. It seems the first version of the ability is ready. @jeffpaul, @dkotter is there any further changes that we should do before merging? Or it is fine to merge and iterate after? Thank you in advance for any insights!

Looks like a lint failure that will need cleaned up but otherwise I'm okay proceeding, though may be worth waiting for @peterwilsoncc to chime back in from his initial review

- Sanitize ability input against the input schema in both callbacks, since
  the Abilities API validates but does not coerce REST GET input (booleans
  arrive as 'true', arrays as CSV strings) and the strict normalizers were
  silently dropping those filters.
- Return a fully redacted user as an object so it serializes as {} rather
  than [] and honors the declared output type.
- Always declare avatar_urls in the schemas and honor show_avatars when
  formatting instead, since the registered schemas are a registration-time
  snapshot while the option is re-read at call time.
- Extract resolve_readable_user() so the single-user authorization decision
  is implemented once and shared by the permission and execute callbacks.
Adversarial review of the previous commit surfaced defects in the
schema-based sanitize pass, so coercion now lives in the normalizers:

- rest_sanitize_value_from_schema() embeds property-level WP_Errors (an
  include list of '1' and '01' validates as unique strings but sanitizes
  to a WP_Error after integer coercion, silently dropping the filter),
  discards schema-valid stdClass input, falls back to raw input when the
  call-time schema drifts from the registration-time snapshot, and
  rebuilds the input schema on every call.
- normalize_string_list() and normalize_include() now accept the CSV
  strings REST GET delivers (the latter via wp_parse_id_list(), which
  also deduplicates string-distinct IDs), normalize_has_published_posts()
  accepts the string forms of true, and stdClass input is treated as its
  array form.
- The id field is always included, matching the REST users controller
  where id is present in every context; a fully redacted result now
  returns the user ID instead of an empty object, which REST
  post-processing (_fields/_embed) cannot handle at the top level.
- Coverage extended: string-duplicate include IDs, numeric-string
  scalars, and object input.
A prior commit removed normalize_per_page(), normalize_string_list(), and
normalize_include() but left their call sites, so PHPStan and the PHP/E2E
suites failed with "Call to undefined method". Restore the normalizers.

Also normalize the username lookup with sanitize_user() so every single-user
lookup field is normalized alongside id (absint) and email (sanitize_email),
keep the slug lookup on the raw nicename to match the REST users controller
(non-canonical nicenames must resolve), and note that collection total/
total_pages may exceed the returned users when the multisite membership guard
skips rows.
The normalizer restore reverted a `phpcs:ignore` that suppresses a Slevomat
false positive (a multi-line array constant read as a multi-constant
definition) on DEFAULT_FIELDS. Restore the annotation so PHPCS passes.
The roles field was returned to any caller with list_users, even for users
they cannot edit. list_users grants no edit rights, so this disclosed the
site's privilege map to a list_users-only role. Gate roles on the same
is-self-or-can-edit check as the other sensitive fields, matching the REST
users controller, where roles is an edit-context field and non-editable rows
are dropped from collections.
The output schema's roles enum was captured at ability registration. A role
registered afterwards is still held by a returned user, so format_user emits
it, but the frozen enum rejects it during output validation and fails the
whole call with ability_invalid_output. Drop the enum from the output roles
items (plain strings, matching the REST users controller); the input roles
enum, which is re-resolved at call time, is unaffected.
The collection loop post-filtered rows by is_user_member_of_site() while
total/total_pages came from the unfiltered WP_User_Query. On multisite a bare
collection query (no roles/has_published_posts) is network-wide, so a
list_users caller got an inflated total and short, near-empty pages.

Drop the per-row membership filter so users, total and total_pages all derive
from the same query, matching WP_REST_Users_Controller::get_items (no per-row
membership check, get_total() reported directly). Single-user lookups keep the
membership check, matching the controller's get_user(). No-op on single site,
where the guard was already always-true.
The comment claimed a bare collection query is network-wide on multisite,
but WP_User_Query scopes results to the current blog via the capabilities
meta clause it adds whenever blog_id (defaulted to the current blog) is set,
even with no role/has_published_posts filter. Correct the note to describe
the actual site-scoping; no behavior change.
@jeffpaul
jeffpaul requested review from gziolo and peterwilsoncc July 7, 2026 14:35
@jorgefilipecosta

Copy link
Copy Markdown
Member Author

Hi @gziolo and @peterwilsoncc thank you a lot for your reviews everything was addressed.

@jorgefilipecosta

Copy link
Copy Markdown
Member Author

Thank you a lot for the review and applying some suggestions @gziolo. It seems the first version of the ability is ready. @jeffpaul, @dkotter is there any further changes that we should do before merging? Or it is fine to merge and iterate after? Thank you in advance for any insights!

Looks like a lint failure that will need cleaned up but otherwise I'm okay proceeding, though may be worth waiting for @peterwilsoncc to chime back in from his initial review

Hi @dkotter, sorry that failure was temporary during the development it is addressed.

gziolo and others added 6 commits July 9, 2026 09:24
The ability validates its result against the output schema, so a value that
does not match the declared type fails the whole call. Two values WordPress
stores can trigger that.

A user can have no email at all. `wp_insert_user()` rejects an empty login and
an empty nicename, but never an empty email. `sanitize_email()` can also repair
an address into a shape `is_email()` rejects, so saving `a@@b.c` stores `a@b.c`.

`get_avatar_url()` returns false when it cannot build a URL, which is how
plugins that suppress avatars opt out. Since `avatar_urls` is part of the
default field set, that failed every call, not only the ones that ask for it.

Both fields now accept null, and `format_user()` maps every unusable value to
it. The email field keeps its `email` format, so a non-null value is always a
valid address. In collection mode a single bad row no longer rejects the whole
page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `is_public_author()` docblock said the method checks for published posts.
That is not what it does. `count_user_posts()` defaults to `$public_only =
false`, so a private post also counts for a caller who holds
`read_private_posts` for that post type.

Collection mode is filtered by `WP_User_Query` with `has_published_posts`, which
matches published posts only. So an editor can resolve an author whose posts are
all private through a single-user lookup, but will never find that author in a
collection.

This keeps the current behavior, which matches the REST users controller. It
exposes nothing new, because an editor who can read the private post already
sees its author through the post itself. The docblock now describes the rule,
and a new test pins all three legs: the editor resolves the author, the
collection omits them, and a subscriber is denied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment above the `include` query argument said the default ordering is kept
"regardless of the requested order". There is no order input, so that clause
described something the ability does not have. It was left over from dropping
`orderby => include` in e01b49d.

The comment now says what is actually being avoided. A new test locks the
behavior in: it runs the same include set in both orders and expects the results
in user login order both times. Adding `orderby => include` back makes it fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Peter pointed out that `public => true` does not mean a post type can be viewed
on the front end, so the ability switched to is_post_type_viewable(). One path
still slipped through. Passing `has_published_posts => true` straight to
WP_User_Query makes core resolve the list itself, as
`get_post_types( array( 'public' => true ) )`, which is the very set Peter warned
about.

The effect: an author whose only published post used a post type registered with
`public => true, publicly_queryable => false` was matched by a boolean filter,
even though the ability rejects that post type in the input enum, in collection
mode for callers who cannot list users, and in the single-user lookup.

Both branches now resolve the post types the same way, so `true` and an explicit
list mean the same thing. Resolving at call time also picks up post types that
were registered or unregistered after the input schema was built, which was
Peter's other point.

A new test registers the post type from his gist and checks all three paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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.

Thank you @jorgefilipecosta and @peterwilsoncc. I pushed six follow-up commits. Here is what each one does:

  • 00f98114 — Bug fix. email and avatar_urls can now be null. WordPress can store a user with no email, and get_avatar_url() can return false. Both failed output validation, and in collection mode one bad row failed the whole page.
  • 110b37f3 — Docs and a test. is_public_author() also counts private posts for a caller who can read them, so a single-user lookup and a collection can disagree. This matches the REST users controller, so we keep it and pin it with a test.
  • a8caabfb — Test fix. Newer WordPress versions sanitize an invalid email in a different way.
  • d70c24b6 — Docs only. The input enums are a snapshot taken when the ability is registered. This is on purpose, so the schema stays a stable contract.
  • 00f9fd6c — Docs and a test. The include order is not used for sorting. The test fails if orderby => include ever comes back.
  • 4bb161eb — Bug fix. has_published_posts: true was passed to WP_User_Query, which resolves it with public => true. That is the gotcha @peterwilsoncc described. The ability now resolves the post types itself, so true and an explicit list mean the same thing.

PHPUnit, PHPCS, and PHPStan all pass.

The core/get-user-info field naming question is a follow-up, not a blocker. In the long run core/get-user-info could become a preset of this ability for the current user.

Approving. Thank you both for the careful work on a sensitive ability.

@jorgefilipecosta

Copy link
Copy Markdown
Member Author

Thank you a lot for all the insights I think this needs a last approval from @dkotter or @jeffpaul so we can merge and continue to iterate.

@dkotter
dkotter merged commit 83f84a3 into develop Jul 9, 2026
26 of 27 checks passed
@dkotter
dkotter deleted the codex/core-users-ability branch July 9, 2026 18:38
@github-project-automation github-project-automation Bot moved this from Needs review to Done in WordPress AI Roadmap Jul 9, 2026
@gziolo gziolo mentioned this pull request Jul 17, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.