feat(adminui): gate the Secrets rail link on the page's own policy
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:
@@ -25,7 +25,19 @@
|
||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||
<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 Title="Scripting" Key="scripting">
|
||||
<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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user