feat(adminui): gate the Secrets rail link on the page's own policy
v2-ci / build (push) Successful in 4m44s
v2-ci / unit-tests (push) Failing after 17m18s

The /admin/secrets link was already in the side rail, but ungated — every
authenticated user saw it, and a Designer/Operator clicking it was bounced by
the page's own [Authorize(Policy = SecretsAuthorization.ManagePolicy)]. Hide
the advertised dead end.

Gated on that same policy constant rather than Roles="Administrator". The two
are equivalent today (the RCL's AdminRole is "Administrator", read off the same
ClaimTypes.Role claim FleetAdmin uses), so this is not about reach — it is that
the rail cannot drift if the RCL ever changes its policy.

Live-gated on docker-dev, both directions, because the positive half alone
proves nothing here: the link rendered for an admin BEFORE this change too, so
"admin still sees it" is satisfied equally by a working gate and by an inert
one. Negative control — gate swapped to Roles="NoSuchRoleFalsifyProbe", rebuilt,
item went to 0 while /role-grants and /apikeys stayed at 1 (the item is hidden,
not the rail broken). Restored build hashed to the same image ID as the
pre-falsification one, proving the restore was byte-exact. This also rules out
AuthorizeView being inert inside ThemeShell's <Nav> slot under this host's
static router, which would have silently hidden the link from everyone.

SecretsNavLinkTests covers it in the AdminUI's actual idiom — source scan +
metadata cross-check, NOT bUnit (the bunit PackageVersion pin has zero
PackageReferences repo-wide). The load-bearing assertion is index ordering
(AuthorizeView open < NavRailItem < close): a scan that merely finds both tags
in the file passes when they are not nested, which is the ungated state.

Scope: Secrets only. /role-grants and /apikeys are FleetAdmin (Administrator-
only) too and stay visible to everyone, so the rail is now inconsistent —
deliberate, as hiding those is a UX call for the whole rail rather than
something to smuggle into this change. Noted in the markup.

Family-wide task from the scadaproj index session (ScadaBridge ee193cd2,
ignitionoee 3627bb8, HistorianGateway 58b6418, mxgw 87d575d).
This commit is contained in:
Joseph Doherty
2026-08-13 09:59:31 -04:00
parent 08e33fc7ec
commit 259d9621ba
2 changed files with 120 additions and 1 deletions
@@ -25,7 +25,19 @@
<NavRailItem Href="/certificates" Text="Certificates" /> <NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" /> <NavRailItem Href="/role-grants" Text="Role grants" />
<NavRailItem Href="/apikeys" Text="API keys" /> <NavRailItem Href="/apikeys" Text="API keys" />
<NavRailItem Href="/admin/secrets" Text="Secrets" /> @* Gated on the page's OWN policy constant, not Roles="Administrator", so the rail
can never drift from what the page actually admits (pinned by SecretsNavLinkTests).
ManagePolicy resolves to AdminRole "Administrator" over the same ClaimTypes.Role
claim FleetAdmin reads.
NOTE this is currently the ONLY gated item, and the rail is therefore inconsistent:
/role-grants and /apikeys are FleetAdmin (Administrator-only) too and stay visible
to everyone, i.e. still advertised dead ends for a Designer/Operator. That is a
deliberate scope boundary, not an oversight — hiding them is a UX call for the
whole rail, filed as a follow-up rather than smuggled in here. *@
<AuthorizeView Policy="@ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy">
<NavRailItem Href="/admin/secrets" Text="Secrets" />
</AuthorizeView>
</NavRailSection> </NavRailSection>
<NavRailSection Title="Scripting" Key="scripting"> <NavRailSection Title="Scripting" Key="scripting">
<NavRailItem Href="/scripts" Text="Scripts" /> <NavRailItem Href="/scripts" Text="Scripts" />
@@ -0,0 +1,107 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// Pins the side-rail link to <c>/admin/secrets</c> and its role gate. The page has worked since
/// lmxopcua#483, but reachability is a separate property from correctness: before this, the only
/// way to the secrets UI was typing the URL, which is the same orphaned-surface shape as Gitea
/// #546 (<see cref="HostsPanelWiringTests"/>) — everything builds, tests and deploys while no
/// user can get there.
/// </summary>
/// <remarks>
/// Source scan rather than a rendering assertion: the repo has no bUnit (the central
/// <c>PackageVersion</c> for it is referenced by no project), and razor layout markup — unlike
/// a page's <c>@page</c>/<c>@attribute</c> directives — does not survive into class metadata
/// any form a reflection guard can read. So this proves the link and its gate are *written*;
/// that the gate actually admits an Administrator and hides the item from a Designer is
/// live-verified on the docker-dev rig, per this repo's Razor convention.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class SecretsNavLinkTests
{
private const string NavItem = "<NavRailItem Href=\"/admin/secrets\"";
private static string ReadMainLayoutSource()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
return File.ReadAllText(Path.Combine(
dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI",
"Components", "Layout", "MainLayout.razor"));
}
/// <summary>The reachability pin: without this the secrets UI is URL-only.</summary>
[Fact]
public void The_rail_links_to_the_secrets_page()
{
ReadMainLayoutSource().ShouldContain(
NavItem,
Case.Sensitive,
customMessage:
"the side rail no longer links to /admin/secrets — the secrets UI is reachable " +
"only by typing the URL, which is how it shipped before this test existed");
}
/// <summary>
/// The link must sit INSIDE the AuthorizeView, not merely somewhere in the same file — an
/// unnested pair would leave the item visible to every authenticated user while reading, to
/// a scan for both strings, exactly like a gated one. Ordering is the strongest structural
/// claim a text scan can make; whether the gate actually hides the item is live-verified.
/// </summary>
[Fact]
public void The_secrets_link_is_nested_inside_the_authorize_view()
{
var source = ReadMainLayoutSource();
var open = source.IndexOf("<AuthorizeView Policy=", StringComparison.Ordinal);
var item = source.IndexOf(NavItem, StringComparison.Ordinal);
var close = source.IndexOf("</AuthorizeView>", open + 1, StringComparison.Ordinal);
open.ShouldBeGreaterThanOrEqualTo(0, "the rail has no policy-gated AuthorizeView at all");
close.ShouldBeGreaterThanOrEqualTo(0, "the gating AuthorizeView is never closed");
item.ShouldBeInRange(open, close,
"the /admin/secrets rail item is outside the AuthorizeView — it is visible to every " +
"authenticated user, and a scan that only looked for both tags would not notice");
}
/// <summary>
/// The gate must be expressed as the page's own policy constant. <c>Roles="Administrator"</c>
/// or a "secrets:manage" literal would render identically today and then silently drift the
/// moment the RCL changed its policy — the rail would advertise a page that rejects the user,
/// or hide one that would have admitted them, with nothing failing.
/// </summary>
[Fact]
public void The_gate_is_the_pages_own_policy_constant_not_a_role_or_a_literal()
{
var source = ReadMainLayoutSource();
source.ShouldContain("SecretsAuthorization.ManagePolicy", Case.Sensitive);
source.ShouldNotContain($"\"{SecretsAuthorization.ManagePolicy}\"", Case.Sensitive,
customMessage: "the rail hard-codes the policy string instead of referencing the constant");
source.ShouldNotContain("<AuthorizeView Roles=", Case.Sensitive,
customMessage: "the rail gates on a role name instead of the page's policy");
}
/// <summary>
/// Cross-check that the constant the rail references is the one the page actually enforces.
/// The two assertions above are satisfiable by a rail gated on a policy no page uses; this is
/// what makes them mean "the rail shows exactly who the page will admit".
/// </summary>
[Fact]
public void The_rails_policy_is_the_one_the_page_enforces()
{
var page = typeof(SecretsAdmin).GetCustomAttributes<AuthorizeAttribute>(inherit: false).Single();
page.Policy.ShouldBe(SecretsAuthorization.ManagePolicy,
"the rail gates on SecretsAuthorization.ManagePolicy, so the page must enforce it");
}
}