Skip to content

Comments: Enforce registered comment type capabilities in map_meta_cap() (Trac #35214)#55

Open
adamsilverstein wants to merge 7 commits into
feature/comment-type-capsfrom
feature/comment-type-cap-enforcement
Open

Comments: Enforce registered comment type capabilities in map_meta_cap() (Trac #35214)#55
adamsilverstein wants to merge 7 commits into
feature/comment-type-capsfrom
feature/comment-type-cap-enforcement

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to PR #52 (the advisory cap object), implementing Stages 1-2 of the enforcement plan: wiring WP_Comment_Type::$cap into map_meta_cap(). Per discussion this supports both models.

Before this, the cap object was advisory: map_meta_cap() only knew edit_comment (derived from the parent post) and all moderation was a bare moderate_comments primitive check at each call site.

What changed (map_meta_cap() only - no call-site reroutes yet)

  • edit_comment generalized. A comment whose type uses the default comment capability model (its mapped edit_comment is the generic meta cap) still derives edit permission from the parent post - byte-for-byte unchanged, orphaned-comment fallback included. A type that opts into its own caps maps to edit_comments (own) / edit_others_comments (others) by user_id, mirroring edit_post.
  • delete_comment added. Default model follows the parent post (matching how core's delete flows gate on edit_comment today); independent model maps to delete_comments.
  • moderate_comment added. Default model requires the global moderate_comments primitive; independent model maps to the type's moderate_comments (e.g. moderate_reviews).

Design: supports both, maps don't grant

  • Default capability_type => 'comment' is unchanged. Built-in comment/pingback/trackback/note all resolve exactly as today, so no role (admin included) is affected.
  • Independent types are gated by their own primitives with no silent fallback. Sites grant the primitives to roles, exactly like custom post types. The default model is detected by the type's mapped meta cap still being the generic string (e.g. cap->edit_comment === 'edit_comment'), which also avoids any self-recursion.

Deferred

  • read_comment - the Comments: Add a capabilities object to comment types #52 cap object has no read primitive, and approved comments are publicly readable, so gating reads needs its own design.
  • Call-site rerouting (Stage 3) - wp_set_comment_status(), admin comment.php + list-table, REST permission callbacks, AJAX handlers - lands as small per-surface PRs so each gets a focused security review. This PR is intentionally map_meta_cap()-only.

Testing

New tests/phpunit/tests/comment/commentCapabilities.php:

  • Default model parity: edit/delete follow the parent post (incl. orphaned fallback), moderation requires moderate_comments.
  • Independent review type: a moderate_reviews user can moderate reviews but not default comments, and an administrator (has moderate_comments, not moderate_reviews) is the exact inverse - proving independence and the no-auto-grant rule.
  • Own-vs-others edit split and independent delete.

Registered the two new meta caps in the core meta-cap coverage list (tests/phpunit/tests/user/capabilities.php). Full --group comment --group capabilities suite passes (797 tests). PHPCS + PHPStan clean.

Review updates

  • The PR now also adds custom meta capability name translation, mirroring _post_type_meta_capabilities(): current_user_can( 'edit_review', $comment_id ) - the names the cap object itself advertises - now translates to the generic meta capability instead of silently falling through to a literal primitive check. Includes a collision guard against post type meta cap names; 'read_comment' is deliberately excluded from translation until a map_meta_cap() case exists for it. unregister_comment_type() cleans up the registered translations.
  • The three map_meta_cap() cases are consolidated into a fall-through implementation with a single documented model-detection point.
  • The capability docblocks were rewritten to describe the live model, including the mixed-model and empty-capability_type footguns.

Ecosystem impact

A case-sensitive regex audit of ~70,000 wordpress.org plugins (70,029 of 70,039 indexed, plus all themes) via Veloria (formerly WPDirectory) found near-zero direct use of the singular delete_comment / moderate_comment strings as capabilities:

  • current_user_can( 'delete_comment' ): 1 plugin, via a custom wrapper unrelated to core caps (search).
  • current_user_can( 'moderate_comment' ): 0 (search).
  • user_can() with either string: 0 (search).
  • add_cap() / has_cap() with either string: 0 (search, search).
  • A catch-all quoted-string search returned 348 matches across 222 plugins (search): 147 are add_action( 'delete_comment' ) hook usages (Akismet, ManageWP, W3TC, Simple History, WP Activity Log, Stream, cache purgers), the rest method names, webhook slugs, logger labels, and nonce actions - all unaffected.

Only two genuine capability uses exist:

  1. Sugar Calendar Lite (10K installs) passes 'delete_comment' to user_can() with a comment ID, expecting core to map it - a check that silently always failed and now behaves as its author intended (the same switch already relies on the delete_post/delete_user meta caps).
  2. Custom Review (<10 installs) uses 'moderate_comment' as an admin menu capability, a typo for moderate_comments. It stays denied (the no-ID branch maps to do_not_allow), now surfacing the _doing_it_wrong() notice.

The compatibility risk profile is equivalent to - and on this evidence lower than - the precedent set when WordPress 6.1 introduced the edit_comment meta capability.

Stacking

Based on feature/comment-type-caps (#52). Retarget to trunk once the registration (#12311) and cap-object PRs land.

See #35214.

Give `WP_Comment_Type` a `cap` object built from new `capability_type` and
`capabilities` registration arguments, modeled on `WP_Post_Type` and
`get_post_type_capabilities()`. A new `get_comment_type_capabilities()` helper
builds the capability strings from the `capability_type` base (default
'comment'), so a registered type can describe its own read, edit, delete, and
moderate capabilities.

This is advisory metadata only: `map_meta_cap()` is intentionally not changed,
so there is no behavior change. The built-in `comment` type resolves to the
existing `edit_comment` and `moderate_comments` capabilities, preserving current
behavior. Enforcing per-type capabilities through `map_meta_cap()` is left to a
follow-up so the capability model can be agreed on first.

See #35214.
Cover the new `capability_type`/`capabilities` arguments and the
`get_comment_type_capabilities()` helper: default and custom capability types,
array capability types with explicit plurals, the `capabilities` override, that
the input `capabilities` array is not retained as a property, and that the
built-in `comment` type stays backward compatible with the existing core
capabilities.

See #35214.
…ap()`.

The capabilities object added for comment types was advisory only;
`map_meta_cap()` knew a single comment cap (`edit_comment`, derived from
the parent post) and all moderation ran through a bare `moderate_comments`
primitive check.

Wire the cap object into `map_meta_cap()` so a registered type can enforce
its own permissions, supporting both models:

- Types using the default `comment` capability model are unchanged. Their
  mapped `edit_comment` is the generic meta cap, so editing and deletion
  still derive from the comment's parent post and moderation still requires
  the global `moderate_comments` primitive.
- A type that opts into its own `capability_type`/`capabilities` is gated by
  its own primitives (e.g. `moderate_reviews`, `edit_others_reviews`),
  mirroring how registered post types map `edit_post`. Caps are mapped, not
  granted: sites assign the primitives to roles, so nothing is silently
  escalated or locked out.

Generalize the `edit_comment` case and add `delete_comment` and
`moderate_comment` meta caps. `read_comment` is deferred: the cap object has
no read primitive and approved comments are publicly readable, so gating
reads needs its own design.

See #35214.
Cover both capability models in `map_meta_cap()`: the default `comment`
model still derives edit/delete from the parent post (including the orphaned
comment fallback) and moderation from `moderate_comments`, while a type with
an independent `capability_type` is gated by its own primitives with no
fallback to the default caps. Register `delete_comment` and `moderate_comment`
in the meta-capability coverage list.

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: 0a3b0518-796b-42e6-b1bd-9dc9d8c5c10f

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-cap-enforcement

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.

…ature/comment-type-cap-enforcement

# Conflicts:
#	tests/phpunit/tests/comment/types.php
current_user_can( 'edit_review', $comment_id ) - the idiom developers
know from custom post types, and the exact names the capability object
advertises - previously fell through map_meta_cap()'s default case to a
literal primitive check that ignored the comment ID and the own/others
split. Mirror _post_type_meta_capabilities(): remember each type's
custom edit_comment/delete_comment/moderate_comment names and translate
them in the default case. read_comment is deliberately excluded until a
corresponding case exists.

- Guard against claiming names already registered as post type meta
  capabilities (those take precedence); reject with _doing_it_wrong().
- Remove a type's translations in unregister_comment_type(), mirroring
  the post type cleanup, and reset the registry between tests.
- Consolidate the three map_meta_cap() comment cases into one
  fall-through block with a single, documented model-detection point
  (overriding a singular meta capability opts that action into the
  independent model), and note that the independent model deliberately
  ignores the parent post.
- Rewrite the 'advisory only' capability docblocks from the caps PR to
  describe the live model, including the mixed-model and empty
  capability_type footguns, and add map_meta_cap()'s @SInCE entry.
- Tests: legacy empty-string types across all three meta caps, orphaned
  independent comments, invalid and missing IDs, anonymous own/others,
  array capability_type end to end, single-override model flips,
  register/unregister fallback, trashed parents, custom-name
  translation, and the post type collision guard.
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