(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
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.