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

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.