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