Skip to content
System nominalUTC --:--:--Articles 080
MODULE 03 Lesson 07 / 18 30 MIN

The admin side

Admin Menus, Screens and Capability Gates

How WordPress turns add_menu_page and add_submenu_page calls into a real admin surface, and why the capability argument is a gate rather than a label.

Sifero Courier Timeline registers nine admin pages from one function that runs on a single hook. Reading that function is easy. Understanding what WordPress does with it is not, and the difference is where the security bugs live.

The obvious worry is a screen that leaks data to someone who should not see it. The less obvious one is a screen whose capability argument looks right and does nothing, because the check happened at the wrong moment in the request. Both are on display in this plugin, and the second one only shows up when you know the order in which the admin loads.

// sifero-courier-timeline-for-woocommerce.php, lines 268-269
add_action( 'admin_menu', 'sfct_admin_menu' );
add_action( 'admin_enqueue_scripts', 'sfct_admin_assets' );

WordPress builds the menu in two passes

The first pass is your code. Everything you call during admin_menu appends to three globals: $menu for top-level items, $submenu keyed by parent slug, and $_registered_pages for the pages that load a callback. The second pass is core's. wp-admin/admin.php requires wp-admin/menu.php, which fires admin_menu from wp-admin/includes/menu.php, and then walks both arrays removing everything the current user cannot use:

// wp-admin/includes/menu.php, WordPress 7.0.7
foreach ( $submenu as $parent => $sub ) {
	foreach ( $sub as $index => $data ) {
		if ( ! current_user_can( $data[1] ) ) {
			unset( $submenu[ $parent ][ $index ] );
			$_wp_submenu_nopriv[ $parent ][ $data[2] ] = true;
		}
	}
}

Element 1 of a menu row is the capability. That is the whole security model of the sidebar: WordPress does not trust the plugin to hide things, it re-tests the capability itself. The Plugin Handbook's Administration Menus page describes the registration side of this and recommends a capability check in the callback as well; the source shows you exactly where each check lands. At the end of the same file WordPress calls user_can_access_admin_page(), and if that returns false you get wp_die( 'Sorry, you are not allowed to access this page.', 403 ).

The capability you pass at registration is tested, but not where you think

sfct_admin_menu() has no capability check of its own. It hands manage_woocommerce to the registration functions and lets them decide.

// includes/admin-pages.php, lines 12-23, shortened
function sfct_admin_menu() {
	$capability = 'manage_woocommerce';

	add_menu_page(
		__( 'Sifero Courier Timeline for WooCommerce', 'sifero-courier-timeline-for-woocommerce' ),
		__( 'Sifero Courier Timeline', 'sifero-courier-timeline-for-woocommerce' ),
		$capability,
		SFCT_ADMIN_MENU_SLUG,
		'sfct_render_dashboard_page',
		'dashicons-location-alt',
		56
	);

Here is the part of add_menu_page() that matters, from wp-admin/includes/plugin.php:

// WordPress 7.0.7
$hookname = get_plugin_page_hookname( $menu_slug, '' );

if ( ! empty( $callback ) && ! empty( $hookname ) && current_user_can( $capability ) ) {
	add_action( $hookname, $callback );
}

The current_user_can() call guards the add_action(), not the menu row. The row is appended to $menu either way, with the capability sitting in it for core's second pass to read. So an admin who lacks manage_woocommerce still has an entry in $menu[56], and core removes it later because that row's capability fails the same test.

The consequence is stronger than it looks. A user without the capability does not get the callback registered at all for the entire request. If they navigate straight to admin.php?page=sfct-couriers, get_plugin_page_hook() returns null because nothing was added to that hook name, so WordPress never reaches the plugin function. add_submenu_page() takes an even blunter route and returns false before touching the array:

// wp-admin/includes/plugin.php, WordPress 7.0.7
if ( ! current_user_can( $capability ) ) {
	$_wp_submenu_nopriv[ $parent_slug ][ $menu_slug ] = true;
	return false;
}

I installed WordPress 7.0.7, activated the plugin with a stub that satisfies its WooCommerce check, and called sfct_admin_menu() as a subscriber. The top-level row appeared in $menu; the callback did not register; $_wp_submenu_nopriv held the parent slug with all nine registered page slugs in it; and user_can_access_admin_page() returned false for both a submenu page and the top-level slug. The gate works. It fires earlier in the request than most people assume.

Why the render callback still checks

Eight of the nine SFCT screen callbacks open with current_user_can( 'manage_woocommerce' ) and wp_die() before they render anything. The exception is sfct_render_dashboard_page(), the top-level page. That one leans entirely on the registration check, which is a fair bet for the happy path and a silent hole the day the capability argument changes or the callback is reached from somewhere else: a load-{$hook} handler, an admin_post_ action, or a re-registration with a different capability. The menu check protects the menu. The callback check protects the function.

The screen id is built from the menu title, not the slug

When admin_enqueue_scripts fires, its argument is the hook suffix, and get_current_screen()->id is that same string. On a normal admin request both come from $hook_suffix, so testing the hook and testing the screen id are the same test written twice. The interesting question is what the string contains.

get_plugin_page_hookname( $plugin_page, $parent_page ) returns $page_type . '_page_' . $plugin_name. For a top-level page, $page_type is the literal toplevel. For a submenu, $page_type is $admin_page_hooks[ $parent ], and add_menu_page() set that to sanitize_title( $menu_title ). Reading the plugin's menu title backwards gives you the prefix:

Toplevel page            admin.php?page=sifero-courier-timeline-for-woocommerce
  hook / screen id       toplevel_page_sifero-courier-timeline-for-woocommerce

Submenu page             admin.php?page=sfct-orders
  hook / screen id       sifero-courier-timeline_page_sfct-orders

Two things follow. First, SFCT_ADMIN_MENU_SLUG is used as the parent slug and as one submenu slug, and that is all. It never appears in a screen id on these screens, so the two tests against it in includes/assets.php are always false on an ordinary request. Second, because the prefix comes from the menu title rather than the slug, two plugins that pick the same menu title share a prefix. A page prefix is not a namespace, and a screen-id check is only as specific as the string you compare.

Only one of the three menu hooks is fired per request

wp-admin/includes/menu.php chooses one hook based on the admin context and never fires the others:

// wp-admin/includes/menu.php, WordPress 7.0.7, shortened
if ( is_network_admin() ) {
	do_action( 'network_admin_menu', '' );
} elseif ( is_user_admin() ) {
	do_action( 'user_admin_menu', '' );
} else {
	do_action( 'admin_menu', '' );
}

The plugin registers on admin_menu only. On a multisite install that means its screens do not exist in the Network Admin. I confirmed the mechanism by loading wp-admin/network/menu.php with WP_NETWORK_ADMIN defined: only network_admin_menu fired, and no SFCT entry appeared in the menu array.

That is a defensible design for this plugin. Its data is per site. But the plugin also hooks wp_initialize_site and wpmu_new_blog to call sfct_install_current_site() on new sites, so it clearly expects to run network-wide. The result is that a network admin can activate the plugin for two hundred sites and then has no network screen for it, only two hundred per-site dashboards. WooCommerce solves the same problem by shipping a Network Admin settings tab, which requires a second registration on network_admin_menu.

There is a shorter-term trap too. manage_woocommerce is a per-site capability, and the super admin only has it inside a site. A super admin who lands on Network Admin → Dashboard gets no Sifero menu because the plugin never registered one, and no error explaining why. If the plugin intends to be network-activated, its readme should say the screens live under each site.

List-level capabilities and object-level capabilities are different jobs

The plugin uses current_user_can( 'manage_woocommerce' ) at 21 call sites and current_user_can( 'edit_shop_order', $order_id ) in the places that act on a single order. Both are correct, and they answer different questions.

manage_woocommerce is a primitive capability. WooCommerce grants it to administrator and shop_manager in WC_Install::get_core_capabilities(). It answers "may this user work with the store at all" and belongs on a menu registration or a screen that lists every order in the shop.

edit_shop_order is a meta capability. A user cannot be handed a single order id at role level, so WordPress resolves it through map_meta_cap() against the actual order. When the plugin writes current_user_can( 'edit_shop_order', $order_id ), it asks "may this user edit this order", which is the question an order screen is really asking. A shop manager who can edit one order cannot necessarily edit another, and a site with a custom role that can see the orders list but only touch orders assigned to their region can express that with the object-level form and not with the primitive one.

The mistake to avoid is the reverse: using edit_shop_order as a list-level check. Without an order id there is nothing to resolve the meta capability against, and the answer stops describing what the code does. The plugin gets this right and uses the object form in includes/ajax.php, includes/woocommerce.php and includes/orders.php wherever an order id is in hand.

Do not hand a screen a capability you would not hand the menu

One thing is worth changing. The plugin's Orders screen is registered with manage_woocommerce, yet its central action, saving tracking details for one order, is guarded with the narrower edit_shop_order. A role that can do the work is refused the screen that offers it. Registration capability and in-screen capability should agree, and when the screen exists to edit orders, the object-level check is the honest one.

Lab 30 minutes

On a local WordPress 7.0.7 with a plugin of your own that registers one top-level menu and two submenus, write the menu registration so the top-level page uses manage_options and the submenus use edit_posts. Log in as an Author, who has edit_posts and not manage_options, and as an Administrator.

For each user, record four things: whether a row appears in the $menu global, whether has_action() finds the page callback, whether the page loads when you visit it by URL, and which of $_wp_menu_nopriv or $_wp_submenu_nopriv contains the slug.

Then answer: for the Author, what is the minimum change that hides the top-level entry entirely, rather than leaving a parent item whose only child is its own dashboard link?

Show the solution and the reasoning

The Author gets the edit_posts submenus and not the manage_options top-level page. That produces a real artefact rather than a clean refusal: core's re-parenting loop in wp-admin/includes/menu.php finds that the top-level page has no accessible submenu of its own, takes the first accessible submenu, and promotes it to be the parent. The Author sees a top-level menu named after their first submenu, and the two submenus underneath it.

So the four observations line up like this. The $menu global holds the top-level row for both users, because add_menu_page() appends unconditionally. has_action() finds the callback only for the Administrator, because the callback is registered inside a current_user_can() test. Direct URL access returns the 403 "not allowed" page for the Author, since the hook was never registered. $_wp_submenu_nopriv is empty for the Administrator and holds none of your slugs for the Author, because add_submenu_page() only writes there when the capability fails and the Author has edit_posts. $_wp_menu_nopriv gets the top-level slug for the Author, and core uses exactly that array to decide whether to unset the row.

Hiding the parent entirely is one line, and it is not a capability argument. Wrap the registration in the test you already mean:

add_action( 'admin_menu', function () {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	add_menu_page( 'Reports', 'Reports', 'manage_options', 'acme-reports', 'acme_render_reports', 'dashicons-chart-bar', 56 );
	add_submenu_page( 'acme-reports', 'Orders', 'Orders', 'edit_posts', 'acme-reports-orders', 'acme_render_orders' );
} );

Now the menu is assembled per user from scratch. Nothing is added that has to be removed, no re-parenting happens, and the Author sees no Reports menu at all. The trade-off is that you have taken over a decision core would have made for you, so you now own it: if a later release adds a submenu that Authors should reach, it has to be registered outside that early return.

The alternative is to leave both registrations in place and accept the re-parented menu, which is what many shipping plugins do. It is not broken. It shows the Author a menu whose name and contents do not match what an administrator sees, which is the kind of thing that generates a support ticket reading "the dashboard is different for my staff".

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.