How to allow “Add New” capability of CPT when links to its UI are placed as a submenu?

(Edit: going for the “Student” badge, and wondering if anyone out there could up vote this question?)

I answered a question about capabilities, but now I’m in need of help on the subject. And after reviewing the manual, I’m even more confused.

I have two custom post types both fully accessible to Administrators. I have Subscribers, some having access to one of these CPT’s which is a “child” of the first and stores it’s parent’s ID in its _adm_id metadata. These “special” Subscribers have access to the parent CPT admin table so they can click a link to create child CPT posts of parents with a special status. Next, the Subscriber is allowed to edit child posts (both its own and those created by others) but only if it is of a particular custom post status. Lastly, special Subscribers are not allowed to delete posts (or edit deleted posts), not even their own.

Here’s what I’ve got (working code) …

// Setup custom post types and statuses
add_action('init', function() {
    // Custom Post Types
    register_post_type('adm-cpt', array(
        'label'             => __('Admin Only CPT'),
        'show_ui'           => TRUE,
        'show_in_menu'      => 'my-menu-item',
        'show_in_admin_bar' => FALSE,
        'capability_type'   => 'adm',
        'map_meta_cap'      => TRUE,
        'capabilities' => array(
            'create_posts'  => 'administrator', // Only admin can create, not special Subscribers
        ),
    ));
    register_post_type('sub-cpt', array(
        'label'             => __('Subscriber/Admin CPT'),
        'show_ui'           => TRUE,
        'show_in_menu'      => 'my-menu-item',
        'show_in_admin_bar' => FALSE,
        'capability_type'   => 'sub',
        'map_meta_cap'      => TRUE,
    ));
    // Custom Post Statuses
    foreach(array(
        'adm-childable'     => __('Can Create Children'),
        'sub-editable'      => __('Any Subscriber Can Edit'),
    ) as $slug => $label) {
        register_post_status($slug, array(
            'label'         => _x($label, 'post'),
            'label_count'   => _n_noop($label .' <span class="count">(%s)</span>', $label .' <span class="count">(%s)</span>' ),
            'public'        => TRUE,
        ));
    }
});

// Setup parent page in admin menu
add_action('admin_menu', function() {
    // Add menu item
    if(current_user_can('administrator')
    || current_user_can('special-subscriber')
    ) {
        // Admin menu header
        add_menu_page(
            NULL,
            'CPTs',
            'exist',
            'my-menu-item',
            ''
        );
    }
});

// Set up role
add_action('wp_roles_init', function($wp_roles){
    // Prepare
    $role="special-subscriber";
    $caps = array(
        'delete_subs'           => FALSE,   // No trashing ...
        'delete_others_subs'    => FALSE,
        'delete_published_subs' => FALSE,
        'delete_private_subs'   => FALSE,
        'edit_published_subs'   => FALSE,   // And no editing published/private posts ...
        'edit_private_subs'     => FALSE,
        'edit_adms'             => TRUE,    // Allow viewing of adm-cpt table
        'edit_posts'            => TRUE,    // WARNING:  Here's the permission that is causing the problems!
    );
    $name = __('"Special" Subscriber');
    // Update role in database, if needed
    if($wp_roles->get_role($role) === NULL
    || $wp_roles->get_role($role)->capabilities != $caps
    || $wp_roles->roles[$role]['name'] !== $name
    ) {
        $wp_roles->remove_role($role);
        $wp_roles->add_role($role, $name, $caps);
    }
});

// Dynamicly set capabilities
add_action('user_has_cap', function($allcaps, $caps, $args, $user) {
    foreach($caps as $cap) {
        $perm = substr($cap, 0, strrpos($cap, '_'));
        $type = substr($cap, strlen($perm)+1);
        if(in_array($type, array('adm', 'adms')) && in_array('administrator', $user->roles)
        || in_array($type, array('sub', 'subs')) && !empty(array_intersect(array('administrator', 'special-subscriber'), $user->roles))
        ) {
            // Check Subscriber if post is editable
            if(in_array($cap, array('edit_subs', 'edit_others_subs'))
            && in_array('special-subscriber', $user->roles)
            && !in_array('administrator', $user->roles)
            && !empty($args[2])
            &&  (   !in_array(get_post_status($args[2]), array('sub-editable'))
                        && !in_array($_REQUEST['original_post_status'], array('sub-editable', 'auto-draft'))    // Creating
                ||  get_post_status(get_post_meta($args[2], '_adm_id', TRUE)) === 'trash'
                )
            ) {
                $allcaps[$cap] = FALSE;
            }
            // Add the cap
            if(!isset($allcaps[$cap])
            ) {
                $allcaps[$cap] = TRUE;  // All the _adm and _sub capabilities are made available.
            }
        }
    }
    return $allcaps;
}, 10, 4);

// Add stuff to force proper navigation
add_action('post_row_actions', function($actions, $post) {
    // Add link to adm-cpt table entries to create child
    if(get_post_type($post) === 'adm-cpt'
    && get_post_status($post) === 'adm-childable'
    && current_user_can('edit_subs')
    ) {
        $lbl = __('New '). get_post_type_object('sub-cpt')->labels->name;
        $actions['adm-cpt-create-sub-cpt'] = sprintf(
            '<a href="https://wordpress.stackexchange.com/questions/367822/%s" aria-label="https://wordpress.stackexchange.com/questions/367822/%s">%s</a>',
            admin_url('post-new.php?post_type=sub-cpt&adm_id='. $post->ID),
            esc_attr('“'. $lbl .'”'),
            $lbl
        );
    }
    // Return
    return $actions;
}, 10, 2);

// Modify publish metabox
add_action('post_submitbox_misc_actions', function($post) {
    $arr = array();
    switch(get_post_type($post)) {
        case 'adm-cpt':
            $arr = array('adm-childable');
            break;
        case 'sub-cpt':
            $arr = array('sub-editable');
            break;
        default:
            return;
    }
    // Check that parent exists -- Should be in an init hook, but it's prettier here.
    if($_REQUEST['post_type'] === 'sub-cpt'
    &&  (empty($_REQUEST['adm_id']) || get_post_type($_REQUEST['adm_id']) !== 'adm-cpt')
    &&  (empty($post->_adm_id) || get_post_type($post->_adm_id) !== 'adm-cpt')
    ){
        ?><script>window.document.location.replace("<?= admin_url('edit.php?post_type=adm-cpt') ?>")</script><?php
        return;
    }
    // Add custom post statuses
    ?><input type="hidden" name="adm_id" value="<?= $_REQUEST["adm_id'] ?>'><?php
    if(count($arr)) {
        ?><script>
        <?php foreach($arr as $k) { $obj = get_post_status_object($k); ?>
            jQuery("select#post_status").append("<option value=\"<?= $k ?>\"><?= $obj->label ?></option>");
            <?php if(get_post_status($post) == $k) { ?>
                jQuery("#post-status-display").text("<?= $obj->label ?>");
                jQuery("select#post_status").val("<?= $k ?>");
            <?php } ?>
        <?php } ?>
        </script><?php
    }
    // Display parent -- Informational
    if(!empty($_REQUEST['adm_id'])
    || !empty($post->_adm_id)
    ) {
        $parent_id = $post->_adm_id;
        if(!$parent_id) $parent_id = $_REQUEST['adm_id'];
        ?><div class="misc-pub-section misc-pub-adm-cpt">Parent:  <span id="post-status-display"><?= get_the_title($parent_id) ?></span></div><?php
    }
});

// Save parent ID
add_action('save_post_sub-cpt', function($post_id, $post, $update) {
    // Ensure we continue only id a new child is created
    if(defined('DOING_AUTOSAVE') && DOING_AUTOSAVE
    || get_post_type($post_id) !== 'sub-cpt'
    || empty($_REQUEST['adm_id'])
    || get_post_type($_REQUEST['adm_id']) !== 'adm-cpt'
    ) return;
    // Set parent ID
    update_post_meta($post_id, '_adm_id', $_REQUEST['adm_id']);
}, 10, 3);

// Navigation when changed to uneditable
add_action('load-post.php', function(){
    if(!empty($_REQUEST['post'])
    && get_post_type($_REQUEST['post']) === 'sub-cpt'
    && !current_user_can('edit_subs', $_REQUEST['post'])
    ) {
        delete_post_meta($_REQUEST['post'], '_edit_lock');
        wp_redirect('edit.php?post_type=sub-cpt');
        die();
    }
});

This issue here is that the special Subscriber is able to edit regular Posts and Comments. I understand this comes from the edit_posts capability, and that capability allows for the editing/creation of all post types. However, removing it prevents special Subscribers from being able to create sub-cpt posts, and granting edit_subs does not solve the problem. Neither does setting the capabilities->create_post=special-subscriber when registering the child CPT. I’ve been able to limit the ability of Subscribers from being able to create adm-cpt posts by defining the capabilities parameter when registering the post type. But I don’t want special Subscribers to be able to edit/create any other posts other than those of the sub-cpt type, and I can’t seem to figure out how.

I’ve found a Q&A related to the subject, but this doesn’t seem to work. The CPT’s are mapped to custom capabilities, they exist, and the user_has_cap filter dynamically grants each of these capabilities. I’ve even tried expressly defining them in the special-subscriber role definition. Anyways, I’m sure the change is simple–what is it?

(If you’re interested, I have another capability problem. When a special Subscriber sets the child CPT post_status to publish, the post is locked and they are forwarded to edit.php but I want the post to unlock and for the viewer to be forwarded to edit.php?post_type=sub-cpt just like is done in the load-post.php hook of my code, and I can’t seem to figure out how.)

UPDATE: I’ve isolated it down to the placement of the CPT in the menu. When the CPT is registered as showing the UI using the register_post_type option of show_in_menu=TRUE, everything works as expected. But, when the CPT is added as a submenu of an old-fashioned admin menu item, things break. Adding the UI and hiding it results in the same problems, along with adding a subpage and redirecting it to the UI of the CPT. Examples:

// 1.)  Works as expected if user has every custom capability
add_action('init', function() {
    register_post_type('sub-cpt', array(
        'label'             => __('Subscriber/Admin CPT'),
        'show_ui'           => TRUE,
        'show_in_menu'      => TRUE,    // Take note of this
        'show_in_admin_bar' => FALSE,
        'capability_type'   => 'sub',
        'map_meta_cap'      => TRUE,
    ));
}

// 2.)  Same as #1 with the exception that access to 'post-new.php' when "Add New" button is clicked is prohibited
add_action('init', function() {
    register_post_type('sub-cpt', array(
        'label'             => __('Subscriber/Admin CPT'),
        'show_ui'           => TRUE,
        'show_in_menu'      => 'my-menu-item',  // Take note of this
        'show_in_admin_bar' => FALSE,
        'capability_type'   => 'sub',
        'map_meta_cap'      => TRUE,
    ));
}
add_action('admin_menu', function() {
    add_menu_page(
        'CPT in title bar',
        'CPT in menu',
        'edit_subs',
        'my-menu-item',
        ''
    );
}

// 3.)  Breaks the same as #2
add_action('init', function() {
    register_post_type('sub-cpt', array(
        'label'             => __('Subscriber/Admin CPT'),
        'show_ui'           => TRUE,
        'show_in_menu'      => FALSE,   // Take note of this
        'show_in_admin_bar' => FALSE,
        'capability_type'   => 'sub',
        'map_meta_cap'      => TRUE,
    ));
}
add_action('admin_menu', function() {
    global $submenu;
    add_menu_page(
        'CPT in title bar',
        'CPT in menu',
        'edit_subs',
        'my-menu-item'
    );
    add_submenu_page(
        'my-menu-item',
        get_post_type_object('sub-cpt')->label,
        get_post_type_object('sub-cpt')->label,
        'edit_subs',
        'my-menu-item-sub'
    );
    // Change link
    $url="edit.php?post_type=sub-cpt";
    $submenu['my-menu-item'][1][2] = admin_url($url);   // Set URL to view CPT
    unset($submenu['my-menu-item'][0]);             // Remove WP generated menu item
});

If, I can get the “Add New” functionality to work with the CPT as a subpage, I think my problem will be solved because the edit_posts capability giving me trouble can be specifically mapped to edit_subs. Anyone know how to do this?

3 Answers
3

The problem is that when the special subscriber tries to Add New a sub-cpt post, it is denied permission. However, when the CPT menu is a top-admin-menu, then everything works out fine. The issue is related to the placement of the CPT’s UI menu in the back-end: if it’s top-level (show_in_menu=TRUE), all is well; if its a submenu (show_in_menu='my-menu-item'), the user can’t create the post type unless it has the edit_posts permission (even if it has all the edit_PostType permissions in the world). I’ve been chasing this stupid thing since the 22nd. Thanks to the pandemic, I haven’t had to do much of anything else. After 12-15 hours each of the 8 days, I finally got this little bugger picked.

This issue had something to do with post-new.php, as all works out fine when the CPT is edited under the post.php script (which is nearly identical). The very first thing that post-new.php does is call on admin.php. On line 153, wp-admin/menu.php is called in to bat which includes wp-admin/includes/menu.php as its last execution. On that includes/menu.php file’s line 341, the user_can_access_admin_page() returns FALSE, triggering the do_action('admin_page_access_denied') hook to be fired and the wp_die(__('Sorry, you are not allowed to access this page.'), 403) command to kill the whole process.

The user_can_access_admin_page() method is defined on line 2042 of the wp-admin/includes/plugin.php file. Line 2064 passed its check in that get_admin_page_parent() was empty. This is followed by line 2078 failing its check in that the variable of $_wp_submenu_nopriv['edit.php']['post-new.php'] is set. The combined effect of these checks the FALSE boolean being returned and WordPress dies.

The closest related script known to me is that of post.php, as the admin.php process is immediately called and runs in an identical manner, including the calling of user_can_access_admin_page(). Debugging demonstrates that the user_can_access_admin_page() is passed in the post.php script because, unlike post-new.php, none of the $_wp_submenu_nopriv[____][$pagenow] flags were set. So, the question is why this index is being set for post-new.php and not set for post.php.

The global $_wp_submenu_nopriv is first set on line 71 of wp-admin/includes/menu.php, in which that variable is initialized as an empty array. If the current_user_can() test is not passed on line 79, the flag is set on line 81. At that point, the global $submenu['edit.php'] is initialized to the point of our concern, and contains the array at *index=*10 (“Add New”, “edit_posts”, “post-new.php”). A review of admin menu positioning) reveals this entry is the Add New link made by the system for standard WP posts. The check that occurs tests whether or not the current user has the permission to edit_posts. As the special Subscriber user cannot edit “posts,” the check fails and the system breaks. When I learned this, the race was on to unset the $submenu['edit.php']['post-new.php'] entry before line 81 of wp-admin/includes/menu.php was executed. If one worked backwards from that line into wp-admin/menu.php, it would be found that the flag at issue is set on line 170 with the execution of $submenu[$ptype_file][10] = array($ptype_obj->labels->add_new, $ptype_obj->cap->create_posts, $post_new_file). So, the hooks fired between these two points in the code will allow us to interject and unset the flag that has caused me so much strife.

The first function called with an available hook after this setting is current_user_can('switch_themes') on line 185. A check in the subsequently called user_has_cap for this squirmy flag will occur more times than one can count, so its not really the best hook to use. Following this, the only direct hooks available are those of _network_admin_menu, _user_admin_menu, or _admin_menu found in /wp-admin/includes/menu.php straight away at the very top of the file (only one of them will fire depending on if the request is for the network administration interface, user administration interface, or neither). Since calling a filter from an unrelated function is a heck of a round-about way of doing things, I chose to make use of these hooks, like so:

add_action('_network_admin_menu', 'pick_out_the_little_bugger');
add_action('_user_admin_menu', 'pick_out_the_little_bugger');
add_action('_admin_menu', 'pick_out_the_little_bugger');
function pick_out_the_little_bugger() {
    // If the current user can not edit posts, unset the post menu
    if(!current_user_can('edit_posts')) {
        global $submenu;
        $problem_child = remove_menu_page('edit.php');  // Kill its parent and get its lineage.
        unset($submenu[$problem_child[2]]);         // "unset" is just too nice for this wormy thing.
    }
}

Jeezers this was a shot in the dark and wayyyy to much work for less than a dozen lines of code! Since I found a bunch of people with this same problem, I opened a ticket to modify the WordPress Core.

Leave a Comment