diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php
index 028e61ec414a8..1fc9a4b8bda93 100644
--- a/src/wp-includes/capabilities.php
+++ b/src/wp-includes/capabilities.php
@@ -572,16 +572,105 @@ function map_meta_cap( $cap, $user_id, ...$args ) {
break;
}
- $post = get_post( $comment->comment_post_ID );
+ $comment_type = get_comment_type_object( $comment->comment_type );
/*
- * If the post doesn't exist, we have an orphaned comment.
- * Fall back to the edit_posts capability, instead.
+ * Comment types using the default 'comment' capability model derive edit
+ * permission from the comment's parent post, preserving historical behavior.
+ * A registered type that opts into its own capabilities (its mapped
+ * `edit_comment` differs from the generic meta capability) is gated by those
+ * primitives instead, mirroring how registered post types map `edit_post`.
*/
- if ( $post ) {
- $caps = map_meta_cap( 'edit_post', $user_id, $post->ID );
+ if ( ! $comment_type || 'edit_comment' === $comment_type->cap->edit_comment ) {
+ $post = get_post( $comment->comment_post_ID );
+
+ /*
+ * If the post doesn't exist, we have an orphaned comment.
+ * Fall back to the edit_posts capability, instead.
+ */
+ if ( $post ) {
+ $caps = map_meta_cap( 'edit_post', $user_id, $post->ID );
+ } else {
+ $caps = map_meta_cap( 'edit_posts', $user_id );
+ }
+ } elseif ( $comment->user_id && (int) $comment->user_id === (int) $user_id ) {
+ $caps[] = $comment_type->cap->edit_comments;
+ } else {
+ $caps[] = $comment_type->cap->edit_others_comments;
+ }
+ break;
+ case 'delete_comment':
+ if ( ! isset( $args[0] ) ) {
+ /* translators: %s: Capability name. */
+ $message = __( 'When checking for the %s capability, you must always check it against a specific comment.' );
+
+ _doing_it_wrong(
+ __FUNCTION__,
+ sprintf( $message, '' . $cap . '' ),
+ '7.1.0'
+ );
+
+ $caps[] = 'do_not_allow';
+ break;
+ }
+
+ $comment = get_comment( $args[0] );
+ if ( ! $comment ) {
+ $caps[] = 'do_not_allow';
+ break;
+ }
+
+ $comment_type = get_comment_type_object( $comment->comment_type );
+
+ /*
+ * As with editing, deletion of a default-model comment follows the
+ * comment's parent post. A type with its own capabilities is gated by its
+ * `delete_comments` primitive.
+ */
+ if ( ! $comment_type || 'delete_comment' === $comment_type->cap->delete_comment ) {
+ $post = get_post( $comment->comment_post_ID );
+
+ if ( $post ) {
+ $caps = map_meta_cap( 'edit_post', $user_id, $post->ID );
+ } else {
+ $caps = map_meta_cap( 'edit_posts', $user_id );
+ }
+ } else {
+ $caps[] = $comment_type->cap->delete_comments;
+ }
+ break;
+ case 'moderate_comment':
+ if ( ! isset( $args[0] ) ) {
+ /* translators: %s: Capability name. */
+ $message = __( 'When checking for the %s capability, you must always check it against a specific comment.' );
+
+ _doing_it_wrong(
+ __FUNCTION__,
+ sprintf( $message, '' . $cap . '' ),
+ '7.1.0'
+ );
+
+ $caps[] = 'do_not_allow';
+ break;
+ }
+
+ $comment = get_comment( $args[0] );
+ if ( ! $comment ) {
+ $caps[] = 'do_not_allow';
+ break;
+ }
+
+ /*
+ * Moderating a default-model comment requires the global `moderate_comments`
+ * primitive, exactly as today. A type with its own capabilities is gated by
+ * its `moderate_comments` primitive (e.g. `moderate_reviews`).
+ */
+ $comment_type = get_comment_type_object( $comment->comment_type );
+
+ if ( $comment_type ) {
+ $caps[] = $comment_type->cap->moderate_comments;
} else {
- $caps = map_meta_cap( 'edit_posts', $user_id );
+ $caps[] = 'moderate_comments';
}
break;
case 'unfiltered_upload':
diff --git a/tests/phpunit/tests/comment/commentCapabilities.php b/tests/phpunit/tests/comment/commentCapabilities.php
new file mode 100644
index 0000000000000..001813edf969b
--- /dev/null
+++ b/tests/phpunit/tests/comment/commentCapabilities.php
@@ -0,0 +1,185 @@
+user->create( array( 'role' => 'administrator' ) );
+ self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) );
+ self::$post_id = $factory->post->create( array( 'post_author' => self::$admin_id ) );
+ }
+
+ public function set_up() {
+ parent::set_up();
+
+ // A comment type with an independent capability model.
+ register_comment_type( 'review', array( 'capability_type' => 'review' ) );
+ }
+
+ public function tear_down() {
+ unregister_comment_type( 'review' );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Creates a comment of the given type, optionally attributed to a user.
+ *
+ * @param string $comment_type Comment type slug.
+ * @param int $user_id Authoring user ID. Default 0 (anonymous).
+ * @return int The new comment ID.
+ */
+ private function make_comment( $comment_type = 'comment', $user_id = 0 ) {
+ return self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_type' => $comment_type,
+ 'user_id' => $user_id,
+ )
+ );
+ }
+
+ /*
+ * Default capability model: behavior must match historical core exactly.
+ */
+
+ /**
+ * @ticket 35214
+ */
+ public function test_default_edit_comment_follows_parent_post() {
+ $comment_id = $this->make_comment();
+
+ $this->assertTrue( user_can( self::$admin_id, 'edit_comment', $comment_id ) );
+ $this->assertFalse( user_can( self::$subscriber_id, 'edit_comment', $comment_id ) );
+ }
+
+ /**
+ * @ticket 35214
+ */
+ public function test_orphaned_comment_edit_falls_back_to_edit_posts() {
+ $comment_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => 0,
+ 'comment_type' => 'comment',
+ )
+ );
+
+ $this->assertTrue( user_can( self::$admin_id, 'edit_comment', $comment_id ) );
+ $this->assertFalse( user_can( self::$subscriber_id, 'edit_comment', $comment_id ) );
+ }
+
+ /**
+ * @ticket 35214
+ */
+ public function test_default_moderate_comment_requires_moderate_comments() {
+ $comment_id = $this->make_comment();
+
+ $this->assertTrue( user_can( self::$admin_id, 'moderate_comment', $comment_id ) );
+ $this->assertFalse( user_can( self::$subscriber_id, 'moderate_comment', $comment_id ) );
+ }
+
+ /**
+ * @ticket 35214
+ */
+ public function test_default_delete_comment_follows_parent_post() {
+ $comment_id = $this->make_comment();
+
+ $this->assertTrue( user_can( self::$admin_id, 'delete_comment', $comment_id ) );
+ $this->assertFalse( user_can( self::$subscriber_id, 'delete_comment', $comment_id ) );
+ }
+
+ /**
+ * @ticket 35214
+ *
+ * @expectedIncorrectUsage map_meta_cap
+ */
+ public function test_moderate_comment_without_comment_id_is_denied() {
+ $this->assertFalse( user_can( self::$admin_id, 'moderate_comment' ) );
+ }
+
+ /*
+ * Independent capability model: gated by the type's own primitives, with no
+ * silent fallback to the default comment capabilities.
+ */
+
+ /**
+ * @ticket 35214
+ */
+ public function test_independent_type_moderation_requires_its_own_primitive() {
+ $review_id = $this->make_comment( 'review' );
+ $comment_id = $this->make_comment( 'comment' );
+
+ $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) );
+ $moderator->add_cap( 'moderate_reviews' );
+
+ // The review moderator can moderate reviews but not default comments.
+ $this->assertTrue( user_can( $moderator->ID, 'moderate_comment', $review_id ) );
+ $this->assertFalse( user_can( $moderator->ID, 'moderate_comment', $comment_id ) );
+
+ // An administrator has moderate_comments but not moderate_reviews: the inverse.
+ $this->assertTrue( user_can( self::$admin_id, 'moderate_comment', $comment_id ) );
+ $this->assertFalse( user_can( self::$admin_id, 'moderate_comment', $review_id ) );
+ }
+
+ /**
+ * @ticket 35214
+ */
+ public function test_independent_type_edit_distinguishes_own_and_others() {
+ $author = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) );
+ $author->add_cap( 'edit_reviews' );
+
+ $own_review = $this->make_comment( 'review', $author->ID );
+ $others_review = $this->make_comment( 'review', self::$admin_id );
+
+ // edit_reviews grants editing one's own review comments only.
+ $this->assertTrue( user_can( $author->ID, 'edit_comment', $own_review ) );
+ $this->assertFalse( user_can( $author->ID, 'edit_comment', $others_review ) );
+
+ // edit_others_reviews grants editing any review comment.
+ $editor = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) );
+ $editor->add_cap( 'edit_others_reviews' );
+
+ $this->assertTrue( user_can( $editor->ID, 'edit_comment', $others_review ) );
+ }
+
+ /**
+ * @ticket 35214
+ */
+ public function test_independent_type_delete_requires_its_own_primitive() {
+ $review_id = $this->make_comment( 'review' );
+
+ $deleter = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) );
+ $deleter->add_cap( 'delete_reviews' );
+
+ $this->assertTrue( user_can( $deleter->ID, 'delete_comment', $review_id ) );
+ $this->assertFalse( user_can( self::$subscriber_id, 'delete_comment', $review_id ) );
+ }
+}
diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php
index b92b0db231ecb..3aac321d2060a 100644
--- a/tests/phpunit/tests/user/capabilities.php
+++ b/tests/phpunit/tests/user/capabilities.php
@@ -552,6 +552,8 @@ public function testMetaCapsTestsAreCorrect() {
$expected['delete_post_meta'],
$expected['add_post_meta'],
$expected['edit_comment'],
+ $expected['delete_comment'],
+ $expected['moderate_comment'],
$expected['edit_comment_meta'],
$expected['delete_comment_meta'],
$expected['add_comment_meta'],