Skip to content

Comments: Make REST comment moderation comment-type aware (Trac #35214, Stage 3)#56

Open
adamsilverstein wants to merge 4 commits into
feature/comment-type-cap-enforcementfrom
feature/comment-type-moderation-rest
Open

Comments: Make REST comment moderation comment-type aware (Trac #35214, Stage 3)#56
adamsilverstein wants to merge 4 commits into
feature/comment-type-cap-enforcementfrom
feature/comment-type-moderation-rest

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

Stage 3 of capability enforcement (follow-up to PR #55): start rerouting real moderation call sites through the new moderate_comment meta capability, beginning with the REST API - the cleanest, most self-contained, most testable surface.

WP_REST_Comments_Controller::check_edit_permission() (the gate for REST update and delete) short-circuited on the global moderate_comments primitive:

if ( current_user_can( 'moderate_comments' ) ) {
    return true;
}
return current_user_can( 'edit_comment', $comment->comment_ID );

That had two problems for a comment type with an independent capability model: a moderator of that type (holding e.g. moderate_reviews) could not act on its comments, while any global moderator could act on every type regardless of its model.

What changed

One-line semantic change in two spots: route the moderator shortcut through the per-comment moderate_comment meta cap (added in #55) instead of the global primitive.

  • check_edit_permission() - the update/delete gate.
  • check_read_permission() - the orphaned-comment branch.

For the default capability model moderate_comment resolves to moderate_comments, so built-in comment / pingback / trackback / note are completely unchanged. A type with its own capability_type is gated by its own moderation primitive.

Why this is the whole REST surface (mostly)

Most per-comment REST gates already call current_user_can( 'edit_comment', $id ), which #55 made type-aware - so they needed no change. The remaining bare moderate_comments checks in the controller are cross-cutting / field-level (collection edit context, setting author / author_ip), where per-single-comment semantics don't apply; those are intentionally left as the global primitive.

Testing

New tests/phpunit/tests/rest-api/rest-comment-type-moderation.php dispatches real POST (update) and DELETE requests:

  • A review moderator (moderate_reviews) and a review editor (edit_others_reviews) can update/delete review comments.
  • A global moderate_comments moderator gets 403 on review comments (independence proven at the REST layer) - and a review moderator gets 403 on default comments (the inverse).
  • Default comments, and the built-in pingback type, still update/delete fine for a global moderator (back-compat).

Full --group comment --group capabilities --group restapi passes (the only failure is a pre-existing environmental oEmbed flake unrelated to comments). PHPCS + PHPStan clean.

Review updates

  • The review found one per-comment moderation gate this PR originally missed: get_item_permissions_check() still used the global primitive for edit-context reads of non-note comments, so a type moderator could update or delete a comment but got a 403 reading it with ?context=edit. It is now routed through the per-comment meta cap (current_user_can( 'moderate_comment', $comment->comment_ID )) to match the update/delete paths.
  • Known divergence, documented in an inline comment and pinned by a test: the ?post=0 orphan collection gate intentionally stays on the global moderate_comments primitive, since it gates a mixed-type collection before any specific comment is known. The result: a global moderator sees independent-type orphans filtered out of the list, while a type moderator is rejected at the gate. A real fix belongs with a future type-parameter rework of the collection endpoint.
  • New tests: the unregistered-type back-compat pin (a global moderator keeps full control over never-registered types - the null-type-object fallback), legacy empty-string types, single-item edit-context reads under each capability model, orphaned reads under both models, and error-code assertions via assertErrorResponse().

Remaining Stage 3 families (future PRs)

Same pattern, one focused PR each so every surface gets its own security review: admin (wp-admin/comment.php is already edit_comment-gated; list-table bulk/ajax-actions.php moderation fallback at line 1000), and XML-RPC (wp.editComment status change). These reach the same map_meta_cap() foundation.

Stacking

Based on feature/comment-type-cap-enforcement (#55). Retarget to trunk as the stack lands behind #12311.

See #35214.

`WP_REST_Comments_Controller::check_edit_permission()` short-circuited on the
global `moderate_comments` primitive, so a moderator of a custom comment type
could not edit or delete comments of that type via REST, while a global
moderator could act on every type regardless of its capability model.

Route the moderator shortcut through the `moderate_comment` meta capability
instead. For comment types using the default capability model this resolves
to `moderate_comments` (behavior unchanged), while a type with its own
capabilities is gated by its own moderation primitive. Apply the same change
to the orphaned-comment branch of `check_read_permission()`.

This consumes the meta capabilities added in the previous commit; it makes no
change for the built-in comment, pingback, trackback, or note types.

See #35214.
Dispatch real update and delete requests through the comments controller to
prove `check_edit_permission()` honors per-type capabilities: a moderator (or
editor) of an independent `review` type can edit and delete review comments,
a global `moderate_comments` moderator cannot touch them, and the default and
built-in ping types behave exactly as before.

See #35214.
@github-actions

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.

Core Committers: Use this line as a base for the props when committing in SVN:

Props adamsilverstein.

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

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 601038fe-39ed-449a-93a5-2a5a955599e2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/comment-type-moderation-rest

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ment types.

get_item_permissions_check() still gated edit-context reads of non-note
comments on the global 'moderate_comments' primitive. After the update
and delete paths moved to the per-comment 'moderate_comment' meta
capability, a type moderator could update or delete a comment (with the
response already returning edit-context data) yet receive 403 reading
the same comment with ?context=edit. Route the non-note branch through
array( 'moderate_comment', $comment->comment_ID ) to match.

Also mark every comment gate that intentionally stays on the global
primitive (collection-level and creation-time gates, including the
documented ?post=0 divergence), add the missing @SInCE entries on
check_edit_permission() and check_read_permission(), and expand the
tests: the unregistered-type back-compat pin (a global moderator keeps
full control of never-registered types), legacy empty-string types,
single-item edit-context reads per model, orphaned-comment reads under
both models, and the ?post=0 collection matrix. Denials now assert
error codes via assertErrorResponse().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant