Skip to content
bitzorcas
中EN

Guide

Menu Visibility, Trees, and Navigation

Explains the admin shortcut, role grants, ancestor closure, three projections, user cache keys, and cycle, orphan, and non-menu-parent boundaries.

Last updated

Menu reads are not raw table queries. They pass through visibility resolution, ancestor closure, projection, and caching. Each stage has distinct security and data-integrity semantics.

1. Visibility decision

yesnoyesno

currentUser.User.Roles

contains admin?

null = all visible

roles empty?

empty collection

Authorization granted module codes

TreeBuilder

admin is a case-insensitive role-name shortcut. It is neither a permission code nor a check of Authorization module assignments.

2. Role names to module codes

A non-admin passes current role names to IMenuStore.GetVisibleModuleCodesAsync; the store calls IAuthorizationAssignmentReader.GetGrantedModuleCodesAsync. Authorization owns the relation rows, and Menu never reads their persistence types directly.

Actual visibility boundary
// ① Roles come from authenticated ICurrentUser, never arbitrary query input.
var roles = currentUser.User.Roles;
if (roles.Contains("admin", StringComparer.OrdinalIgnoreCase))
return null; // null means “all,” not a failure.
// ② No roles explicitly means no visible modules.
if (roles.Count == 0)
return Array.Empty<string>();
// ③ Every other path uses Authorization's narrow reader.
return await menuStore.GetVisibleModuleCodesAsync(roles, cancellationToken);

3. Ancestor closure

If only orders.detail is granted, its parents must still render a path. The builder finds each visible Code and walks ParentId upward, adding every ancestor Id.

excluded

granted leaf

parent

root

ungranted sibling

The upward walk stops when resultIds.Add sees an existing Id, so that phase has some repetition protection. The later recursive tree build has no visited set.

4. Code lookup boundary

The builder calls ToDictionary for non-null Codes. The unique index normally prevents duplicates, but historic bad data, database collation, or adapter differences can still surface an exception.

A granted Code missing from the catalog is silently ignored. That tolerates retired modules but can hide relation drift without operational diagnostics.

5. Three projections

MethodOutputFiltering
GetFlatAsyncMenuListItem[]Enabled + visible/ancestors; keeps non-menu rows
GetTreeAsyncMenuNode[]same, recursively nested
GetNavigationAsyncNavigationItem[]same, then IsMenu=true only

Flat omits Description, legacy MVC fields, IsMenu, and Scope. Tree includes Code, IsMenu, and Enabled. Navigation omits Code, OrderSort, Scope, and Enabled.

6. Tree construction

Root keys normalize null and 0: lookup uses ParentId ?? "0" and the entry call is BuildChildren(null). Children order by OrderSort ascending.

Consume a tree safely
// ① The service has already filtered visibility and included ancestors.
var tree = await menuTreeBuilder.GetTreeAsync(currentUser, cancellationToken);
// ② Never infer permission from Name; Code is the stable module key.
foreach (var node in tree)
RenderNode(node.Id, node.Name, node.LinkUrl, node.Children);
// ③ The target endpoint authorizes again when the link is opened.
// A menu response is not a capability token.

7. Non-menu parent trap

Navigation removes IsMenu=false rows before building its lookup. A menu child under a non-menu parent keeps the missing ParentId and becomes unreachable from the root.

Tree and Flat can still contain it. That creates intentional or accidental disagreement among the three APIs, so a management validator must reject or explicitly support this shape.

8. Disabled parent behavior

The store loads only Enabled rows. Disabling a parent while leaving a child enabled makes the child unreachable. Toggle changes only the selected row and does not cascade state.

Re-enabling the parent makes still-enabled descendants appear again. This is projection hiding, not a persisted cascade.

9. Cycles and orphans

BuildChildren has neither visited tracking nor a maximum depth. A root-reachable cycle can recurse indefinitely and cause StackOverflow; a disconnected cycle simply disappears. Current writes allow both shapes.

GA needs transactional write checks, defensive read guards, a startup integrity scan, repair tooling, and metrics for rejected graphs.

10. Cache keys

Areas are menu-tree, menu-flat, and menu-nav, with User scope. The segment is:

  • fixed admin for administrators;
  • visxxxxxxxx, from sorted visible Codes through System.HashCode, otherwise;
  • CachePolicy.Medium(), currently 15 minutes;
  • tagged with the menus area tag.

A changed grant set creates a new key, and write broadcasts clear older entries. System.HashCode is not a durable cross-process hash, but these keys serve only local cache lifetimes today.

11. Authentication and resource authorization

JWT authentication

GetNavigation

tree / flat / detail

menus.menu.view pipeline

role-module visibility filter

navigation data

target endpoint authorization

Navigation is authenticated because generated endpoints authorize by default. It omits IAuthorizedRequest, so it needs no menu-admin view permission. Tree, Flat, and Detail require both authentication and view permission.

12. Required boundary tests

Fix case-insensitive admin, empty roles, leaf ancestor closure, unknown Code, non-menu parents, disabled parents, orphans, self-parent, two-node cycles, equal order, cache grant changes, and independent denial by a target endpoint.

Current focused application tests cover only Detail success and not-found. The tree policy has no dedicated unit suite.

13. Inspection commands

Terminal window
# Read the complete visibility and tree algorithms.
sed -n '1,280p' src/Platform/Menu/*Application/MenuTreeBuilder.cs
sed -n '1,180p' src/Platform/Menu/*Application/MenuVisibilityResolver.cs
# Verify that generated navigation still requires authentication.
rg -n "RequireAuthorization.*true|Append\(\"\.RequireAuthorization" src/Framework/BitzOrcas.Endpoint* -g '*.cs'

Module overview · Security and GA

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%