3a6b2a1daf
Makes the /raw device tree addressable by an ACL rule, and builds the
scope-path producer PermissionTrieBuilder has accepted since it was written
but which had no caller anywhere.
The plan for this stage said "add NodeHierarchyKind.Raw, whose path is the
RawPath segments Folder/Driver/Device/TagGroup/Tag". That shape is wrong.
RawFolder.ParentRawFolderId and TagGroup.ParentTagGroupId are both
self-referencing, so folders and tag groups each nest without bound and a real
RawPath is folder…/Driver/Device/group…/Tag. NodeScope was fixed-arity and
PermissionTrie.CollectMatches was five hardcoded steps, so the scope model
could not address a real raw tree at all. NodeScope is now {ClusterId, Kind,
Segments} and the walk is a segment loop. The trie STRUCTURE was already
variable-depth (NodeAclPath.Segments is a list, Children is a recursive
dictionary), so only the walker and the scope record had to change.
Four decisions worth keeping:
- The namespace level became the realm. Level 1 gated on NodeScope.NamespaceId,
but v3 has no Namespace entity — the snapshot was retired when the address
space went dual-namespace, and the identifier appears nowhere in production
outside NodeScope. So the level could only ever fail to match, which silently
made every sub-cluster grant unreachable while cluster grants kept working.
It is now a realm token derived from the scope's Kind, not carried on it, so
the walk and the producer cannot disagree about its spelling.
- Provenance comes from the granting row's own ScopeKind, recorded on TrieGrant
at build time. Depth-derived provenance could not name a raw level at all,
since the depth of "Device" varies with folder nesting.
- The trie comparer moved from OrdinalIgnoreCase to Ordinal. Raw sibling-name
uniqueness is ordinal, so Speed and speed can both exist under one device and
a grant on either would have matched both.
- The null-scopePaths "deterministic test mode" is deleted. It planted
unresolvable rows at a direct child of the trie root, a position the walker
cannot reach, so the grant looked placed and could never match — and every
test using that mode proved nothing about production placement. scopePaths is
now required, and a row that does not resolve is dropped with a diagnostic.
A scope resolves to a LIST of paths: a raw tag projected into N equipments is
reachable at N+1 NodeIds and a write through any of them lands on the same
driver reference, so a Tag-scoped grant covering only the raw node would be
bypassable through a UNS one. The lookup is keyed on (ScopeKind, ScopeId), not
ScopeId alone — ScopeId has no FK and no type discriminator, so an id collision
across entity types would hand a grant the wrong node's paths.
Also closed here, all found by review rather than asked for:
- NodeAcl rows had no deploy-time validation of any kind and were not reachable
from DraftValidator at all (DraftSnapshot carried no NodeAcls). They are now
gated for dangling ids, cross-cluster scopes, blank/contradictory ScopeId, the
retired FolderSegment kind, and undefined ordinals.
- ConfigDbApiKeyStore conferred roles from Cluster-scoped grants authored in ANY
cluster, because it filtered on scope kind with no ClusterId predicate while
ArtifactApiKeyStore read an already-cluster-filtered snapshot. On a fused
admin+driver node that was a cross-cluster over-grant. Its doc comment claimed
the two stores matched.
- The two AdminUI ACL banners still said enforcement was LDAP-group -> role
mapping, which stages 1/2 made false in both directions.
- The /apikeys page was never classified in PageAuthorizationGuardTests, so that
test has been red since the key-minting commit merged — I did not run
AdminUI.Tests before merging stage 4.
Falsified rather than assumed: 8 mutations against the trie tests, 5 against the
deploy gate (including one proving a cross-cluster row is not silently
mis-diagnosed as unresolved), a staged red->green for the over-grant regression,
and the realm gate, the comparer and the malformed-path guard each broken
individually to confirm only their own tests go red.
Node ACLs are still not ENFORCED — nothing constructs a NodeScope or calls the
evaluator yet. That is stages 5 and 6; this stage makes the model able to
express what those will enforce.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
191 lines
9.6 KiB
C#
191 lines
9.6 KiB
C#
using System.Reflection;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Uns;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Authorization;
|
|
|
|
/// <summary>
|
|
/// Reflection guard over every routable AdminUI page's class-level authorization metadata.
|
|
/// This is the substitute for bUnit (the repo has no bUnit; razor <c>@attribute</c>/<c>@page</c>
|
|
/// compile to class-level attributes, so a metadata scan is authoritative and needs no rendering).
|
|
/// It is the anti-drift anchor: a new page fails until the author consciously classifies it.
|
|
/// Guard universe = <c>typeof(Routes).Assembly</c> — the exact assembly Routes.razor hands
|
|
/// <c><Router AppAssembly=…></c>, so the guard sees precisely the router's page set.
|
|
/// </summary>
|
|
public class PageAuthorizationGuardTests
|
|
{
|
|
/// <summary>
|
|
/// Target classification: every routable page → its required <see cref="AdminUiPolicies"/> policy.
|
|
/// This is the 38-page census from the R2-05 plan. <see cref="AnonymousPages"/> holds the sole
|
|
/// <c>[AllowAnonymous]</c> page. Together they must exactly cover the router's page set.
|
|
/// </summary>
|
|
private static readonly IReadOnlyDictionary<Type, string> ExpectedPolicy = new Dictionary<Type, string>
|
|
{
|
|
// ── ConfigEditor (11): the config-authoring surface (incl. live ResilienceConfig).
|
|
// The routed /clusters/{id}/drivers* driver-authoring pages (DriverTypePicker,
|
|
// DriverEditRouter, + the 8 per-type *DriverPage shells) retired in B2-WP8 — driver
|
|
// authoring now flows through the /raw tree, so those routes no longer exist. ──
|
|
[typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(NewCluster)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(ClusterEdit)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(NodeEdit)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(NamespaceEdit)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(AclEdit)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(Deployments)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
|
|
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
|
|
|
|
// ── FleetAdmin (2) ──
|
|
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
|
// Minting a key hands out a data-plane credential, and the secret is shown once at creation —
|
|
// the same authority level as editing role grants.
|
|
[typeof(ApiKeys)] = AdminUiPolicies.FleetAdmin,
|
|
|
|
// ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
|
|
// carries the RCL page's own policy, registered additively by AddAdminUI via
|
|
// AddSecretsAuthorization(). ──
|
|
[typeof(SecretsAdmin)] = global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,
|
|
|
|
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
|
|
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(ScriptLog)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(AlarmsHistorian)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(Account)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Certificates)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(Reservations)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(ClustersList)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(ClusterOverview)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(ClusterAudit)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(ClusterAcls)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(ClusterNamespaces)] = AdminUiPolicies.AuthenticatedRead,
|
|
[typeof(ClusterRedundancy)] = AdminUiPolicies.AuthenticatedRead,
|
|
};
|
|
|
|
/// <summary>The only page that must remain anonymously reachable (Admin-001 — the login form).</summary>
|
|
private static readonly IReadOnlySet<Type> AnonymousPages = new HashSet<Type> { typeof(Login) };
|
|
|
|
private static readonly string[] KnownPolicyNames =
|
|
[
|
|
AdminUiPolicies.FleetAdmin,
|
|
AdminUiPolicies.DriverOperator,
|
|
AdminUiPolicies.ConfigEditor,
|
|
AdminUiPolicies.AuthenticatedRead,
|
|
// Not an AdminUiPolicies constant: the /admin/secrets wrapper re-states the Secrets.Ui
|
|
// RCL page's own policy, which AddAdminUI registers via AddSecretsAuthorization().
|
|
global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,
|
|
];
|
|
|
|
private static IReadOnlyList<Type> RoutablePages() =>
|
|
[.. typeof(Routes).Assembly.GetTypes()
|
|
.Where(t => t.GetCustomAttributes<RouteAttribute>(inherit: false).Any())
|
|
.OrderBy(t => t.FullName, StringComparer.Ordinal)];
|
|
|
|
private static string RouteOf(Type t) =>
|
|
string.Join(", ", t.GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template));
|
|
|
|
private static AuthorizeAttribute? Authorize(Type t) =>
|
|
t.GetCustomAttributes<AuthorizeAttribute>(inherit: false).SingleOrDefault();
|
|
|
|
private static bool IsAnonymous(Type t) =>
|
|
t.GetCustomAttributes<AllowAnonymousAttribute>(inherit: false).Any();
|
|
|
|
/// <summary>
|
|
/// Rule 1 — no bare gates. Every routable page must carry either <c>[AllowAnonymous]</c> or an
|
|
/// <c>[Authorize]</c> with a non-empty Policy and null Roles. Bans bare <c>[Authorize]</c>, the
|
|
/// <c>Roles="…"</c> idiom, and attribute-less pages.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EveryRoutablePage_carriesAnExplicitNamedPolicyOrAllowAnonymous()
|
|
{
|
|
var offenders = new List<string>();
|
|
foreach (var page in RoutablePages())
|
|
{
|
|
if (IsAnonymous(page))
|
|
continue;
|
|
|
|
var auth = Authorize(page);
|
|
if (auth is null)
|
|
{
|
|
offenders.Add($"{page.FullName} ({RouteOf(page)}): no [Authorize]/[AllowAnonymous] attribute");
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(auth.Policy))
|
|
offenders.Add($"{page.FullName} ({RouteOf(page)}): bare [Authorize] — no Policy");
|
|
if (!string.IsNullOrEmpty(auth.Roles))
|
|
offenders.Add($"{page.FullName} ({RouteOf(page)}): uses Roles=\"{auth.Roles}\" idiom — convert to a named policy");
|
|
}
|
|
|
|
offenders.ShouldBeEmpty(
|
|
"Pages must carry an explicit named policy (or [AllowAnonymous]). Offenders:\n" +
|
|
string.Join("\n", offenders));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rule 2 — exhaustive classification. The router's page set must equal the expected map plus the
|
|
/// anonymous allowlist, and every page's actual policy must match the expected one. A brand-new page
|
|
/// fails here until the author classifies it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EveryRoutablePage_isClassifiedAndMatchesExpectedPolicy()
|
|
{
|
|
var routable = RoutablePages().ToHashSet();
|
|
var classified = new HashSet<Type>(ExpectedPolicy.Keys);
|
|
classified.UnionWith(AnonymousPages);
|
|
|
|
var unclassified = routable.Except(classified)
|
|
.Select(t => $"{t.FullName} ({RouteOf(t)})").OrderBy(s => s).ToList();
|
|
unclassified.ShouldBeEmpty(
|
|
"Routable pages missing from the guard's expected map — classify each in ExpectedPolicy or AnonymousPages:\n" +
|
|
string.Join("\n", unclassified));
|
|
|
|
var stale = classified.Except(routable)
|
|
.Select(t => t.FullName ?? t.Name).OrderBy(s => s).ToList();
|
|
stale.ShouldBeEmpty(
|
|
"Guard expected-map entries that are no longer routable pages — remove them:\n" +
|
|
string.Join("\n", stale));
|
|
|
|
var mismatches = new List<string>();
|
|
foreach (var (page, expected) in ExpectedPolicy)
|
|
{
|
|
var actual = Authorize(page)?.Policy;
|
|
if (actual != expected)
|
|
mismatches.Add($"{page.FullName} ({RouteOf(page)}): expected policy '{expected}', got '{actual ?? "<none>"}'");
|
|
}
|
|
|
|
mismatches.ShouldBeEmpty(
|
|
"Pages whose actual policy differs from the expected classification:\n" +
|
|
string.Join("\n", mismatches));
|
|
}
|
|
|
|
/// <summary>Rule 3 — every Policy value in use is one of the four known <see cref="AdminUiPolicies"/> constants.</summary>
|
|
[Fact]
|
|
public void EveryPolicyNameInUse_isAKnownAdminUiPolicy()
|
|
{
|
|
var unknown = new List<string>();
|
|
foreach (var page in RoutablePages())
|
|
{
|
|
var policy = Authorize(page)?.Policy;
|
|
if (!string.IsNullOrEmpty(policy) && !KnownPolicyNames.Contains(policy))
|
|
unknown.Add($"{page.FullName} ({RouteOf(page)}): unknown policy '{policy}'");
|
|
}
|
|
|
|
unknown.ShouldBeEmpty(
|
|
"Pages referencing a policy name not defined in AdminUiPolicies:\n" +
|
|
string.Join("\n", unknown));
|
|
}
|
|
}
|