Authorisation
Capabilities and map_meta_cap
How WordPress turns a question about one post or record into the capabilities a user holds, and how to register your own object capabilities.
A review finds a handler that checks current_user_can( 'editor' ) and passes,
because an editor can do the thing and an administrator can too. Then somebody installs a plugin
that lets a customer account manage its own posts, and the role assignment stops matching the
capability list. The check was asking about a container rather than a permission, and the answer
is wrong in both directions.
The fix is not a different role name. Stop asking about roles, and learn what
current_user_can() does underneath: pass it an object ID and it runs
map_meta_cap() first, which is where WordPress decides what "edit post 42" means for
this user and this post. Most capability bugs live in that gap. The check is correct and the
arguments are wrong, or a capability is registered on activation and never removed, or the wrong
function is used for the wrong user.
A role is a container of capabilities, and only capabilities answer questions
Roles and capabilities are stored in different places and mean different things. A role is a
name in the wp_user_roles option with a list of capabilities attached. A capability
is a string such as edit_posts or upload_files that is either present in
a user's capability list or absent from it.
Core makes no distinction between them in the API. current_user_can( 'editor' )
works, because a role name is stored as a capability-like entry on the user, and that is exactly
why it is dangerous. Grant the editor capabilities to a custom role, or remove
upload_files from editors, and the check means something else without anyone touching
your code. The core documentation on current_user_can() puts it plainly: checking
against roles in place of a capability is supported in part and discouraged, because it produces
unreliable results.
Two functions do the checking and differ only in whose capabilities they read.
current_user_can( $capability, ...$args ) checks the current user, and since
WordPress 5.8 it is a wrapper for user_can(). user_can( $user, $capability,
...$args ) takes a user ID or a WP_User object, which is what you want when
rendering a "can this person do it" column for other accounts, or in a scheduled job with no
current user.
The difference is also a trap. current_user_can() reads
wp_get_current_user(), so calling it inside a loop over other users gives the same
answer every time. And user_can( 0, 'edit_posts' ) does not fail: it builds an
anonymous user and answers for a visitor, useful on a deliberate anonymous path and misleading
everywhere else.
Primitive capabilities are held, and meta capabilities are resolved
Almost every capability you meet is primitive: a role or a user either has it or does not.
edit_posts, manage_options, upload_files and
read are primitive, and so is edit_acme_shipments once you register a
custom post type.
A meta capability is a question, not a permission. It takes an object and maps the question
onto primitives. Core recognises a small set by name: edit_post,
read_post, delete_post, publish_post,
edit_comment, edit_user, edit_term and the meta-key
capabilities such as edit_post_meta. A custom post type that sets
'map_meta_cap' => true registers its own singular names,
edit_acme_shipment and the rest, as aliases that resolve back to the core ones.
Resolution produces a list of primitives the user must all hold. For the default roles on WordPress 7.0.7, with post 100 published and written by user 3:
// map_meta_cap() running against the WordPress 7.0.7 source, users on default roles.
map_meta_cap( 'edit_post', 3, 100 );
// Post 100 is published and written by user 3, who has the author role.
// array( 'edit_published_posts' )
map_meta_cap( 'edit_post', 3, 101 );
// Post 101 is published and written by user 2.
// array( 'edit_others_posts', 'edit_published_posts' )
map_meta_cap( 'edit_post', 4, 100 );
// User 4 is a contributor: no edit_others_posts, no edit_published_posts.
// array( 'edit_others_posts', 'edit_published_posts' )
The resolution depends on facts only the object knows: who wrote it and whether it is published. That is why the primitive list is computed at check time rather than stored on the user.
Drop the object argument and the check stops being about the object
The most common capability bug in plugin code is a meta capability checked without its object.
Since WordPress 6.1, that produces a _doing_it_wrong() notice and a denial, which is
better than the old behaviour but silent in production.
// Wrong: no object, so there is nothing to resolve.
current_user_can( 'edit_post' );
// _doing_it_wrong: "When checking for the edit_post capability, you must always
// check it against a specific post." Result: false.
// Right: the object supplies the author and the status.
current_user_can( 'edit_post', $post_id );
The notice is the visible half. With WP_DEBUG off, the check simply fails closed,
or a primitive is checked that accidentally does what the author wanted, which is worse. A handler
that authorises with current_user_can( 'edit_posts' ) and then edits whatever ID
arrived in the request has not checked the object at all. It has checked that the user can edit
some posts, which is the condition a contributor editing somebody else's post satisfies.
The rule applies to every meta capability with an object. edit_user takes a user
ID, edit_comment a comment ID, edit_term a term ID, and
edit_post_meta a post ID and a meta key. For your own object capability, the object
argument is whatever your map_meta_cap callback expects in $args[0].
do_not_allow is not a permission you can grant
Some branches of map_meta_cap() return do_not_allow as the required
capability: a deleted post, a revision, a comment with no parent, an unfiltered upload on a
multisite install. WP_User::has_cap() removes do_not_allow from the
capability list before checking, so no user can satisfy it. It is a denial that a filter cannot
turn back into an allowance by granting a capability.
Register and remove custom capabilities deliberately
A plugin that adds an object type has two jobs that the capability_type argument
does half of. Passing 'capability_type' => 'acme_shipment' and
'map_meta_cap' => true tells core which strings to resolve, and creates no
capabilities at all. Nothing holds edit_acme_shipments until you grant it, including
administrators.
// Fragment of a fictional plugin. Not runnable on its own.
register_activation_hook( __FILE__, 'acme_shipments_activate' );
function acme_shipments_activate() {
$role = get_role( 'administrator' );
if ( $role ) {
$role->add_cap( 'edit_acme_shipments' );
$role->add_cap( 'edit_others_acme_shipments' );
$role->add_cap( 'delete_acme_shipments' );
}
// A second role for the staff who handle shipments.
add_role(
'acme_shipping_clerk',
'Shipping clerk',
array( 'read', 'edit_acme_shipments', 'upload_files' )
);
}
The symmetry matters more than the grant. Removing the plugin leaves
edit_acme_shipments on the administrator role forever, owned by nothing. A plugin
that later registers a capability with that name inherits the grant for every administrator. Clean
up in uninstall.php, which the documentation for
register_uninstall_hook() recommends over the hook for this kind of work:
// uninstall.php, in the plugin root.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
$acme_caps = array(
'edit_acme_shipments',
'edit_others_acme_shipments',
'delete_acme_shipments',
);
foreach ( array( 'administrator', 'acme_shipping_clerk' ) as $acme_role_name ) {
$role = get_role( $acme_role_name );
if ( ! $role ) {
continue;
}
foreach ( $acme_caps as $acme_cap ) {
$role->remove_cap( $acme_cap );
}
}
remove_role( 'acme_shipping_clerk' );
Two details decide whether this works. Roles are stored per site, so on multisite the removal
runs once per site: iterate get_sites() and switch context around the loop. And users
can hold capabilities directly, outside any role, through WP_User::add_cap(). That
grant survives remove_cap() on the role, so an uninstall that cleans only roles can
leave an account holding a capability nobody remembers issuing.
The map_meta_cap filter is where your own object type gets its rules
A registered post type gets you core's meta capability names. Your own object, an order, an
invoice, a licence key in your own table, does not, and neither does a rule that is not "who wrote
it" but "which customer owns it". For those, filter map_meta_cap and return the
primitives your code checks. The filter receives the resolved list, the capability name, the user
ID and the arguments, and it runs for every capability on every check, so return
$caps untouched for anything that is not yours.
// Fragment of a fictional plugin. Not runnable on its own.
add_filter( 'map_meta_cap', 'acme_invoices_map_meta_cap', 10, 4 );
function acme_invoices_map_meta_cap( $caps, $cap, $user_id, $args ) {
if ( 'edit_acme_invoice' !== $cap ) {
return $caps;
}
$invoice_id = isset( $args[0] ) ? absint( $args[0] ) : 0;
$owner_id = acme_invoice_owner( $invoice_id );
if ( ! $owner_id || $owner_id !== (int) $user_id ) {
return array( 'do_not_allow' );
}
return array( 'edit_acme_invoices' );
}
Three properties matter under review. It resolves the object by ID rather than trusting
anything else in the request. It returns a primitive the user still has to hold, so an owner
without edit_acme_invoices is refused. And it denies by default: an unknown ID
returns do_not_allow rather than falling through to whatever core would have decided.
The failure mode to know about is the empty return value, because has_cap() loops
over the required capabilities and returns true when the list is empty. Returning
array() from this filter grants the capability to every user on the site.
Two limits are worth knowing before you rely on the filter. A registered post type reaches the
filter under the core name rather than its own: filter for edit_acme_shipment and you
never see it, because the default branch of map_meta_cap() returns the result of the
recursive call for edit_post and returns before the filter runs. A logging callback
that records $cap on a custom post type check prints edit_post, once. And
the filter cannot grant a capability the user does not hold; the primitives you return are still
checked against the user's list.
manage_options is a gate on the whole settings screen, not on your option
The capability people reach for when they are unsure is manage_options, because it
is what the administrator role has and it makes the handler work during testing. It is worth being
precise about how much it carries. In wp-admin/options.php, core gates the entire
settings submission endpoint on one check:
$capability = apply_filters( "option_page_capability_{$option_page}", 'manage_options' );
if ( ! current_user_can( $capability ) ) {
wp_die( 'You need a higher level of permission.', 403 );
}
That file is the generic POST target for the Settings API. Any account that passes the check
reaches the update path for every option group registered on that screen, not only the ones your
plugin added. So if your handler is "clear the shipment cache", manage_options is not
a placeholder for a decision; it is a decision to hand the settings screen to everyone who needs
the cache cleared.
The rule that survives contact with a codebase: name the narrowest capability that matches the
operation, and if none exists, create one and grant it deliberately. Content operations map to
edit_posts or a taxonomy capability, file writes to upload_files, and a
site-wide setting is the only thing that genuinely maps to manage_options. There are
fewer of those than the code suggests.
An invoicing plugin stores invoices in its own table, with an owner_id column,
and wants a check of the form current_user_can( 'edit_acme_invoice', $invoice_id ).
Write the map_meta_cap callback and the grant and removal code. You will need
three pieces.
First, a function acme_invoice_owner( $invoice_id ) that returns the owner's
user ID or 0. Write it so a missing row is not an error.
Second, the filter callback. Decide what it returns for each of these: an invoice owned by the current user, an invoice owned by somebody else, an ID that does not exist, and the administrator account. Write down the argument that settles the last one, because it is a decision, not a default.
Third, the activation and uninstall code. Grant
edit_acme_invoices to a new acme_billing role rather than to
administrator, and explain what an administrator then cannot do that they could
before.
Finish by listing the capabilities that would be left in the database if the plugin were deleted without the uninstall file, and what that leaves behind on a site that installs a fork of the plugin a year later.
Show the solution and the reasoning
// Fragment of a fictional plugin. Not runnable on its own.
function acme_invoice_owner( $invoice_id ) {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare( "SELECT owner_id FROM {$wpdb->prefix}acme_invoices WHERE id = %d", absint( $invoice_id ) )
);
}
add_filter( 'map_meta_cap', 'acme_invoices_map_meta_cap', 10, 4 );
function acme_invoices_map_meta_cap( $caps, $cap, $user_id, $args ) {
if ( 'edit_acme_invoice' !== $cap ) {
return $caps;
}
$owner_id = acme_invoice_owner( $args[0] ?? 0 );
if ( ! $owner_id || $owner_id !== (int) $user_id ) {
return array( 'do_not_allow' );
}
return array( 'edit_acme_invoices' );
}
Run against the WordPress 7.0.7 capability code, with invoice 500 owned by user 5 and an
edit_acme_invoices grant applied:
user_can( 5, 'edit_acme_invoice', 500 ) owner, and holds the primitive true
user_can( 3, 'edit_acme_invoice', 500 ) not the owner, filter says deny false
user_can( 3, 'edit_acme_invoice', 501 ) owner of 501, holds the primitive true
user_can( 5, 'edit_acme_invoice', 999 ) no such invoice false
user_can( 1, 'edit_acme_invoice', 500 ) administrator, not the owner false
The last line is the one the filter is really deciding. do_not_allow is not
something the administrator role can hold, so on this code an administrator cannot edit a
customer's invoice at all.
The administrator case is the one to argue about out loud. Under this code an administrator
with no explicit grant is refused, because do_not_allow is not a permission the role
holds and cannot be granted. That is defensible for customer billing data. The alternative is to
let an administrator through:
if ( user_can( $user_id, 'manage_options' ) ) {
return array( 'edit_acme_invoices' ); // and grant that cap to administrator
}
Choose one and write the reason in a comment. What you should not do is leave it undecided,
because the resolution is that nobody can read an invoice and the first support ticket produces a
patch that returns array( 'read' ) with no argument attached.
The grants belong to the plugin, not to the administrator role. A dedicated
acme_billing role with read and
edit_acme_invoices means an administrator cannot edit an invoice until somebody
decides they should be able to. The cost is a second role to explain, and a support step the
first time the finance team hires. The benefit is that the administrator capability list does
not grow with every plugin, which is how manage_options stops meaning anything on a
mature site.
Without an uninstall file, edit_acme_invoices and the
acme_billing role survive deletion. A fork installed a year later that reuses
edit_acme_invoices inherits every account the original plugin granted it to, with no
way to tell which grant came from where. On multisite that is true per site.
Progress
Mark this lesson done and it stays marked in this browser.