Capability-Driven Development

A presentation by @felixarntz

Definitions

Capabilities in WordPress describe certain tasks
a user may or may not be allowed to perform.

Roles in WordPress are responsible for determining
which capabilities a user has or does not have.

WordPress Capabilities

  • read
  • edit_posts
  • upload_files
  • manage_categories
  • manage_options
  • ...

Custom Capabilities

  • read_ct_tutorials
  • edit_ct_tutorials
  • manage_ct_options
  • ...

Why worry about capabilities?

  • Security: Don't give access to someone who shouldn't have access.
  • Usability: Only show the areas which the user actually needs.
  • Customizability: Allow other developers to tweak access to your functionality.

Internals: The options Table

The available roles for a site are stored in the options database table
as a serialized array.

The capabilities that a specific role grants are also part of that array.

array(
	'administrator' => array(
		'name'         => 'Administrator',
		'capabilities' => array(
			'switch_themes'          => true,
			'edit_themes'            => true,
			'activate_plugins'       => true,
			'edit_plugins'           => true,
			'edit_users'             => true,
			'edit_files'             => true,
			'manage_options'         => true,
			'moderate_comments'      => true,
			'manage_categories'      => true,
			'manage_links'           => true,
			'upload_files'           => true,
			'import'                 => true,
			'unfiltered_html'        => true,
			'edit_posts'             => true,
			'edit_others_posts'      => true,
			'edit_published_posts'   => true,
			'publish_posts'          => true,
			'edit_pages'             => true,
			'read'                   => true,
			'edit_others_pages'      => true,
			'edit_published_pages'   => true,
			'publish_pages'          => true,
			'delete_pages'           => true,
			'delete_others_pages'    => true,
			'delete_published_pages' => true,
			'delete_posts'           => true,
			'delete_others_posts'    => true,
			'delete_published_posts' => true,
			'delete_private_posts'   => true,
			'edit_private_posts'     => true,
			'read_private_posts'     => true,
			'delete_private_pages'   => true,
			'edit_private_pages'     => true,
			'read_private_pages'     => true,
			'delete_users'           => true,
			'create_users'           => true,
			'unfiltered_upload'      => true,
			'edit_dashboard'         => true,
			'update_plugins'         => true,
			'delete_plugins'         => true,
			'install_plugins'        => true,
			'update_themes'          => true,
			'install_themes'         => true,
			'update_core'            => true,
			'list_users'             => true,
			'remove_users'           => true,
			'promote_users'          => true,
			'edit_theme_options'     => true,
			'delete_themes'          => true,
			'export'                 => true,
		),
	),
	'editor'        => array(
		'name'         => 'Editor',
		'capabilities' => array(
			'moderate_comments'      => true,
			'manage_categories'      => true,
			'manage_links'           => true,
			'upload_files'           => true,
			'unfiltered_html'        => true,
			'edit_posts'             => true,
			'edit_others_posts'      => true,
			'edit_published_posts'   => true,
			'publish_posts'          => true,
			'edit_pages'             => true,
			'read'                   => true,
			'edit_others_pages'      => true,
			'edit_published_pages'   => true,
			'publish_pages'          => true,
			'delete_pages'           => true,
			'delete_others_pages'    => true,
			'delete_published_pages' => true,
			'delete_posts'           => true,
			'delete_others_posts'    => true,
			'delete_published_posts' => true,
			'delete_private_posts'   => true,
			'edit_private_posts'     => true,
			'read_private_posts'     => true,
			'delete_private_pages'   => true,
			'edit_private_pages'     => true,
			'read_private_pages'     => true,
		),
	),
	'author'        => array(
		'name'         => 'Author',
		'capabilities' => array(
			'upload_files'           => true,
			'edit_posts'             => true,
			'edit_published_posts'   => true,
			'publish_posts'          => true,
			'read'                   => true,
			'delete_posts'           => true,
			'delete_published_posts' => true,
		),
	),
	'contributor'   => array(
		'name'         => 'Contributor',
		'capabilities' => array(
			'edit_posts'   => true,
			'read'         => true,
			'delete_posts' => true,
		),
	),
	'subscriber'    => array(
		'name'         => 'Subscriber',
		'capabilities' => array(
			'read'    => true,
		),
	),
);

Internals: The usermeta Table

The roles that a user has are stored in the usermeta database table
under that user's ID, also as a serialized array.

Additional capabilities can theoretically also be part of that array,
independent of roles.

array(
	'editor' => true,
);

Guidelines for Plugin Developers

  1. Check for capabilities, don't worry about roles.
  2. Never add capabilities to the database, unless you introduce an entire new role.
  3. Use custom capabilities for your code instead of existing core capabilities.

Checking for Capabilities

current_user_can( string $capability )

or

user_can( int|WP_User $user, string $capability )

Types of Capabilities

  • Primitive Capabilities
  • Meta Capabilities

Primitive Capabilities

  • general capabilities
  • either granted via a role from the database or via the user_has_cap filter
current_user_can( 'edit_posts' )
current_user_can( 'activate_plugins' )

Meta Capabilities

  • capabilities specific to a certain item (an object ID, a string identifier, ...)
  • mapped to one or more primitive capabilities via the map_meta_cap filter
current_user_can( 'edit_post', $post_id )
current_user_can( 'activate_plugin', $plugin_basename )

Special Capabilities

The following two capabilities have special handling:

  • exist: Everybody has this capability, even non logged-in users.
  • do_not_allow: Nobody has this capability, not even super admins.

Naming Conventions

{action}_{items} for primitive capabilities

{action}_{item} for meta capabilities

Internals: Flow for Checking for a Capability

  1. Check for a capability
    current_user_can()
  2. Check if it is a meta capability and map it to its required primitive capabilities
    map_meta_cap()apply_filters( 'map_meta_cap' )
  3. Run logic to alter the user's primitive capabilities from the database
    apply_filters( 'user_has_cap' )
  4. If the user's primitive capabilities now include all required primitive capabilities, they can proceed.

Managing Capabilities
in your Plugins

Our Small Plugin: "Capability Tutorials"

github.com/felixarntz/capability-tutorials

  • adds a tutorial post type
  • adds a settings screen with some options
    to customize the behavior of the post type

Checking for Capabilities

current_user_can( string $capability )

developer.wordpress.org/reference/functions/current_user_can/

function ct_add_settings_page() {
	$hook_suffix = add_submenu_page(
		'edit.php?post_type=' . CT_POST_TYPE_SINGULAR,
		__( 'Tutorial Settings', 'capability-tutorials' ),
		__( 'Settings', 'capability-tutorials' ),
		'manage_ct_options',
		'ct_settings_page',
		'ct_render_settings_page'
	);

	add_action( "load-{$hook_suffix}", 'ct_initialize_settings_page' );
}
add_action( 'admin_menu', 'ct_add_settings_page' );

add_menu_page() and add_submenu_page() are examples where you have to specify a capability. WordPress will internally use current_user_can() there.

Without Capability Checks

function ct_initialize_settings_page() {
	add_settings_field( 'rewrite_slug', __( 'Rewrite Slug', 'capability-tutorials' ), 'ct_render_settings_page_rewrite_slug_field', CT_OPTION_GROUP, 'default', array(
		'label_for' => 'ct-rewrite-slug',
	) );
	add_settings_field( 'supports', __( 'Supported Features', 'capability-tutorials' ), 'ct_render_settings_page_supports_field', CT_OPTION_GROUP, 'default' );
	add_settings_field( 'has_archive', __( 'Archive', 'capability-tutorials' ), 'ct_render_settings_page_has_archive_field', CT_OPTION_GROUP, 'default' );
}

With Capability Checks

function ct_initialize_settings_page() {
	if ( current_user_can( 'manage_ct_option', 'ct_rewrite_slug' ) ) {
		add_settings_field( 'rewrite_slug', __( 'Rewrite Slug', 'capability-tutorials' ), 'ct_render_settings_page_rewrite_slug_field', CT_OPTION_GROUP, 'default', array(
			'label_for' => 'ct-rewrite-slug',
		) );
	}
	if ( current_user_can( 'manage_ct_option', 'ct_supports' ) ) {
		add_settings_field( 'supports', __( 'Supported Features', 'capability-tutorials' ), 'ct_render_settings_page_supports_field', CT_OPTION_GROUP, 'default' );
	}
	if ( current_user_can( 'manage_ct_option', 'ct_has_archive' ) ) {
		add_settings_field( 'has_archive', __( 'Archive', 'capability-tutorials' ), 'ct_render_settings_page_has_archive_field', CT_OPTION_GROUP, 'default' );
	}
}
What?

→ You still need to grant the capabilities appropriately!

Granting Primitive Capabilities

apply_filters( 'user_has_cap', array $allcaps, array $caps, array $args, WP_User $user )

developer.wordpress.org/reference/hooks/user_has_cap/

function ct_maybe_grant_capabilities( $allcaps ) {
	// Allow managing plugin options depending on the user having the 'manage_options' capability.
	if ( isset( $allcaps['manage_options'] ) ) {
		$allcaps['manage_ct_options'] = $allcaps['manage_options'];
	}

	return $allcaps;
}
add_filter( 'user_has_cap', 'ct_maybe_grant_capabilities' );
What?

→ You still need to map the meta capabilities to primitive capabilities!

Mapping Meta Capabilities

apply_filters( 'map_meta_cap', array $caps, string $cap, int $user_id, array $args )

developer.wordpress.org/reference/hooks/map_meta_cap/

function ct_map_meta_capabilities( $caps, $cap, $user_id, $args ) {
	switch ( $cap ) {
		// Maps the meta capability for a single option to the primitive capability for options.
		case 'manage_ct_option':
			$caps = array( 'manage_ct_options' );
			break;
	}

	return $caps;
}
add_filter( 'map_meta_cap', 'ct_map_meta_capabilities', 10, 4 );

So what's the benefit of all that?

While the default behavior may not be much different compared to if you had not used custom capability checks, you allow other developers to improve:

  • Security
  • Usability
  • Customizability

Example 1

Instead of using the user_has_cap filter to grant capabilities, add a custom "Tutorial Manager" role and only grant the capabilities to users of that role.

function mysetup_add_tutorial_manager_role() {
	add_role( 'tutorial_manager', __( 'Tutorial Manager', 'my-setup' ), array(
		'edit_ct_tutorials'             => true,
		'edit_others_ct_tutorials'      => true,
		'publish_ct_tutorials'          => true,
		'read_private_ct_tutorials'     => true,
		'create_ct_tutorials'           => true,
		'edit_private_ct_tutorials'     => true,
		'edit_published_ct_tutorials'   => true,
		'delete_ct_tutorials'           => true,
		'delete_others_ct_tutorials'    => true,
		'delete_private_ct_tutorials'   => true,
		'delete_published_ct_tutorials' => true,
		'read_ct_tutorials'             => true,
		'manage_ct_options'             => true,
	) );
}
register_activation_hook( __FILE__, 'mysetup_add_tutorial_manager_role' );

remove_filter( 'user_has_cap', 'ct_maybe_grant_capabilities' );

Example 2

When in a multisite, allow changing the rewrite slug only to the network administrator (or someone else who has the manage_network_options capability).

function mysetup_tweak_rewrite_slug_capability( $caps, $cap, $user_id, $args ) {
	if ( 'manage_ct_option' === $cap ) {
		$caps = array( 'manage_ct_options' );
		if ( ! empty( $args ) && 'ct_rewrite_slug' === $args[0] && is_multisite() ) {
			$caps[] = 'manage_network_options';
		}
	}

	return $caps;
}
add_filter( 'map_meta_cap', 'mysetup_tweak_rewrite_slug_capability', 11, 4 );

Multisite Support

The network administrator (sometimes also called super admin) appears to be an additional role. It is actually just a simple flag, allowing a user to have all capabilities except do_not_allow.

That being said, still check against capabilities, using custom network-related capabilities as needed. Do not use is_super_admin()!

Primitive Multisite Capabilities in Core

  • manage_sites
  • create_sites
  • delete_sites
  • manage_network
  • manage_network_themes
  • manage_network_plugins
  • manage_network_users
  • manage_network_options
  • upgrade_network

Differences between Multisite and Single-Site

Often times, on multisite you may wanna grant a capability to network administrators only, while otherwise regular administrators should have it too.

Not so great

function map_install_plugin_cap( $caps, $cap, $user_id, $args ) {
	if ( 'install_plugin' === $cap ) {
		$caps = array( 'install_plugins' );
		if ( is_multisite() && ! is_super_admin( $user_id ) ) {
			$caps[] = 'do_not_allow';
		}
	}
	return $caps;
}

Much better

function map_install_plugin_cap( $caps, $cap, $user_id, $args ) {
	if ( 'install_plugin' === $cap ) {
		$caps = array( 'install_plugins' );
		if ( is_multisite() ) {
			$caps[] = 'manage_network_plugins';
		}
	}
	return $caps;
}

Thank you!

Felix Arntz

Plugin Developer / Core Committer / Freelancer
Core Developer & Consultant at Yoast

Further Resources