Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 07 / 09 35 MIN

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.

Lab 40 minutes

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.