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/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php
index 6f88c5e7b499d..b30e13f33d01c 100644
--- a/src/wp-includes/class-wp-comment-type.php
+++ b/src/wp-includes/class-wp-comment-type.php
@@ -94,6 +94,32 @@ final class WP_Comment_Type {
*/
public $show_ui;
+ /**
+ * The string to use to build the read, edit, and delete capabilities.
+ *
+ * May be passed as an array to allow for alternative plurals when using
+ * this argument as a base to construct the capabilities, e.g.
+ * array( 'story', 'stories' ). Default 'comment'.
+ *
+ * @since 7.1.0
+ * @var string|array
+ */
+ public $capability_type = 'comment';
+
+ /**
+ * Capabilities for this comment type.
+ *
+ * Built by {@see get_comment_type_capabilities()} from the
+ * `capability_type` and `capabilities` arguments. This is advisory metadata
+ * describing the capabilities associated with the comment type; the
+ * capability mapping in {@see map_meta_cap()} is not affected by this
+ * property in this release.
+ *
+ * @since 7.1.0
+ * @var stdClass
+ */
+ public $cap;
+
/**
* Whether this comment type is a native or "built-in" comment type.
*
@@ -187,12 +213,14 @@ public function set_props( $args ) {
* treated as a provided value and overwrite the default name with false.
*/
$defaults = array(
- 'labels' => array(),
- 'description' => '',
- 'public' => true,
- 'internal' => false,
- 'show_ui' => null,
- '_builtin' => false,
+ 'labels' => array(),
+ 'description' => '',
+ 'public' => true,
+ 'internal' => false,
+ 'show_ui' => null,
+ 'capability_type' => 'comment',
+ 'capabilities' => array(),
+ '_builtin' => false,
);
$args = array_merge( $defaults, $args );
@@ -204,6 +232,15 @@ public function set_props( $args ) {
$args['name'] = $this->name;
+ // Build the capabilities object, then remove the input array from the props.
+ $this->cap = get_comment_type_capabilities( (object) $args );
+ unset( $args['capabilities'] );
+
+ // Collapse an array capability type back to its singular base.
+ if ( is_array( $args['capability_type'] ) ) {
+ $args['capability_type'] = $args['capability_type'][0];
+ }
+
foreach ( $args as $property_name => $property_value ) {
$this->$property_name = $property_value;
}
diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php
index 2ebc55a1804b0..95c7a073f445f 100644
--- a/src/wp-includes/comment.php
+++ b/src/wp-includes/comment.php
@@ -365,8 +365,16 @@ function create_initial_comment_types() {
* the admin interface or by front-end users. Default true.
* @type bool $internal Whether the comment type is for internal use only and should be
* excluded from default public-facing contexts. Default false.
- * @type bool $show_ui Whether to generate and allow a UI for managing this comment type
- * in the admin. Default is value of $public.
+ * @type bool $show_ui Whether to generate and allow a UI for managing this comment
+ * type in the admin. Default is value of $public.
+ * @type string|array $capability_type The string to use to build the read, edit, and delete
+ * capabilities. May be passed as an array to allow for
+ * alternative plurals when using this argument as a base to
+ * construct the capabilities, e.g. array( 'story', 'stories' ).
+ * Default 'comment'.
+ * @type string[] $capabilities Array of capabilities for this comment type. $capability_type
+ * is used as a base to construct capabilities by default.
+ * See get_comment_type_capabilities().
* }
* @return WP_Comment_Type|WP_Error The registered comment type object on success,
* WP_Error object on failure.
@@ -566,6 +574,53 @@ function get_comment_type_labels( $comment_type_object ) {
return $labels;
}
+/**
+ * Builds an object with all comment type capabilities out of a comment type object.
+ *
+ * Comment type capabilities use the `capability_type` argument as a base, if
+ * the capability is not set in the `capabilities` argument.
+ *
+ * This is advisory metadata describing the capabilities associated with a comment
+ * type, modeled on {@see get_post_type_capabilities()}. The capability mapping in
+ * {@see map_meta_cap()} is not affected by these capabilities in this release.
+ *
+ * The capability strings are built from the `capability_type` argument, which may
+ * be a string or an array. When a string, the plural is created by appending an
+ * 's'. When an array, the first element is the singular base and the second the
+ * plural base, e.g. array( 'story', 'stories' ).
+ *
+ * @since 7.1.0
+ *
+ * @param object $args Comment type registration arguments. Expects the
+ * `capability_type` and `capabilities` properties.
+ * @return object Object with all the capabilities as member variables.
+ */
+function get_comment_type_capabilities( $args ) {
+ if ( ! is_array( $args->capability_type ) ) {
+ $args->capability_type = array( $args->capability_type, $args->capability_type . 's' );
+ }
+
+ // Singular base for meta capabilities, plural base for primitive capabilities.
+ list( $singular_base, $plural_base ) = $args->capability_type;
+
+ $default_capabilities = array(
+ // Meta capabilities.
+ 'edit_comment' => 'edit_' . $singular_base,
+ 'read_comment' => 'read_' . $singular_base,
+ 'delete_comment' => 'delete_' . $singular_base,
+ 'moderate_comment' => 'moderate_' . $singular_base,
+ // Primitive capabilities used outside of map_meta_cap().
+ 'edit_comments' => 'edit_' . $plural_base,
+ 'edit_others_comments' => 'edit_others_' . $plural_base,
+ 'delete_comments' => 'delete_' . $plural_base,
+ 'moderate_comments' => 'moderate_' . $plural_base,
+ );
+
+ $capabilities = array_merge( $default_capabilities, $args->capabilities );
+
+ return (object) $capabilities;
+}
+
/**
* Retrieves all of the WordPress supported comment statuses.
*
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/comment/types.php b/tests/phpunit/tests/comment/types.php
index 54f8e2aa9ad33..dd7fa6aaa4c7b 100644
--- a/tests/phpunit/tests/comment/types.php
+++ b/tests/phpunit/tests/comment/types.php
@@ -291,4 +291,66 @@ static function ( $labels ) {
$this->assertSame( 'Filtered Foo', get_comment_type_object( 'foo' )->labels->singular_name );
}
+
+ /**
+ * @ticket 35214
+ */
+ public function test_registered_comment_type_exposes_cap_object() {
+ register_comment_type( 'foo', array( 'capability_type' => 'review' ) );
+
+ $cobj = get_comment_type_object( 'foo' );
+
+ $this->assertSame( 'edit_reviews', $cobj->cap->edit_comments );
+ $this->assertSame( 'moderate_reviews', $cobj->cap->moderate_comments );
+ }
+
+ /**
+ * The built-in comment type's capabilities match the existing core comment capabilities.
+ *
+ * @ticket 35214
+ */
+ public function test_built_in_comment_type_capabilities_are_backward_compatible() {
+ $cobj = get_comment_type_object( 'comment' );
+
+ $this->assertSame( 'edit_comment', $cobj->cap->edit_comment );
+ $this->assertSame( 'moderate_comments', $cobj->cap->moderate_comments );
+ }
+
+ /**
+ * @ticket 35214
+ *
+ * @covers ::get_comment_type_capabilities
+ */
+ public function test_get_comment_type_capabilities_from_string() {
+ $caps = get_comment_type_capabilities(
+ (object) array(
+ 'capability_type' => 'review',
+ 'capabilities' => array(),
+ )
+ );
+
+ $this->assertSame( 'edit_review', $caps->edit_comment );
+ $this->assertSame( 'edit_reviews', $caps->edit_comments );
+ $this->assertSame( 'edit_others_reviews', $caps->edit_others_comments );
+ $this->assertSame( 'delete_review', $caps->delete_comment );
+ $this->assertSame( 'moderate_reviews', $caps->moderate_comments );
+ }
+
+ /**
+ * @ticket 35214
+ *
+ * @covers ::get_comment_type_capabilities
+ */
+ public function test_get_comment_type_capabilities_honors_capabilities_override() {
+ $caps = get_comment_type_capabilities(
+ (object) array(
+ 'capability_type' => 'comment',
+ 'capabilities' => array(
+ 'edit_comments' => 'manage_stuff',
+ ),
+ )
+ );
+
+ $this->assertSame( 'manage_stuff', $caps->edit_comments );
+ }
}
diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php
index bf6d3c7df28dd..8429c69f88b15 100644
--- a/tests/phpunit/tests/comment/wpCommentType.php
+++ b/tests/phpunit/tests/comment/wpCommentType.php
@@ -117,4 +117,81 @@ public function test_reset_default_labels_clears_cache() {
$labels = WP_Comment_Type::get_default_labels();
$this->assertSame( 'Comments', $labels['name'][0] );
}
+
+ /**
+ * @ticket 35214
+ *
+ * @covers ::set_props
+ */
+ public function test_default_capability_type_and_cap_object() {
+ $comment_type = new WP_Comment_Type( 'foo' );
+
+ $this->assertSame( 'comment', $comment_type->capability_type );
+ $this->assertIsObject( $comment_type->cap );
+ $this->assertSame( 'edit_comment', $comment_type->cap->edit_comment );
+ $this->assertSame( 'moderate_comments', $comment_type->cap->moderate_comments );
+ }
+
+ /**
+ * @ticket 35214
+ *
+ * @covers ::set_props
+ */
+ public function test_custom_capability_type_builds_cap_object() {
+ $comment_type = new WP_Comment_Type( 'foo', array( 'capability_type' => 'review' ) );
+
+ $this->assertSame( 'review', $comment_type->capability_type );
+ $this->assertSame( 'edit_review', $comment_type->cap->edit_comment );
+ $this->assertSame( 'edit_reviews', $comment_type->cap->edit_comments );
+ $this->assertSame( 'moderate_reviews', $comment_type->cap->moderate_comments );
+ }
+
+ /**
+ * An array capability type allows an explicit plural and is collapsed to its singular base.
+ *
+ * @ticket 35214
+ *
+ * @covers ::set_props
+ */
+ public function test_array_capability_type_uses_explicit_plural() {
+ $comment_type = new WP_Comment_Type( 'foo', array( 'capability_type' => array( 'story', 'stories' ) ) );
+
+ $this->assertSame( 'story', $comment_type->capability_type );
+ $this->assertSame( 'edit_story', $comment_type->cap->edit_comment );
+ $this->assertSame( 'edit_stories', $comment_type->cap->edit_comments );
+ }
+
+ /**
+ * @ticket 35214
+ *
+ * @covers ::set_props
+ */
+ public function test_capabilities_argument_overrides_generated_caps() {
+ $comment_type = new WP_Comment_Type(
+ 'foo',
+ array(
+ 'capability_type' => 'review',
+ 'capabilities' => array(
+ 'moderate_comments' => 'manage_reviews',
+ ),
+ )
+ );
+
+ $this->assertSame( 'manage_reviews', $comment_type->cap->moderate_comments );
+ // Non-overridden caps are still generated from the capability type.
+ $this->assertSame( 'edit_reviews', $comment_type->cap->edit_comments );
+ }
+
+ /**
+ * The input `capabilities` array is consumed and not kept as a public property.
+ *
+ * @ticket 35214
+ *
+ * @covers ::set_props
+ */
+ public function test_capabilities_input_is_not_retained_as_property() {
+ $comment_type = new WP_Comment_Type( 'foo', array( 'capabilities' => array( 'edit_comments' => 'x' ) ) );
+
+ $this->assertObjectNotHasProperty( 'capabilities', $comment_type );
+ }
}
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'],