Merge R2-05 AdminUI authorization (arch-review round 2) [PR #428]
Finding 04/C-1 (High): explicit named-policy gating on all 38 AdminUI pages via new AdminUiPolicies (ConfigEditor + AuthenticatedRead) + anti-drift reflection guard. T15 live pass deferred (docker-dev auto-admin can't observe deny). STATUS.md conflict resolved additively. Build clean.
This commit is contained in:
+186
@@ -0,0 +1,186 @@
|
||||
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.Clusters.Drivers;
|
||||
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 (20): the config-authoring surface (incl. live ResilienceConfig) ──
|
||||
[typeof(GlobalUns)] = 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(DriverTypePicker)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(DriverEditRouter)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(ModbusDriverPage)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(S7DriverPage)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(AbCipDriverPage)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(AbLegacyDriverPage)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(TwinCATDriverPage)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(FocasDriverPage)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(OpcUaClientDriverPage)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(GalaxyDriverPage)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(Deployments)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
|
||||
|
||||
// ── FleetAdmin (1) ──
|
||||
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
||||
|
||||
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) ──
|
||||
[typeof(Home)] = 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(ClusterDrivers)] = 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,
|
||||
];
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the semantics of the four AdminUI authorization policies registered by
|
||||
/// <c>AddOtOpcUaAuth</c>. Mirrors <see cref="AddOtOpcUaAuthWiringTests"/> provider
|
||||
/// construction (in-memory config, real registration, resolve <see cref="IAuthorizationService"/>).
|
||||
/// These are the CI-side negative-authz proof: the docker-dev rig auto-authenticates with
|
||||
/// all roles (DisableLogin=true) so a role-denied path can never be observed live.
|
||||
/// </summary>
|
||||
public class AdminUiPoliciesTests
|
||||
{
|
||||
private static IAuthorizationService BuildAuthorizationService()
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Auth:DisableLogin"] = "false",
|
||||
}).Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOtOpcUaAuth(config);
|
||||
|
||||
var sp = services.BuildServiceProvider();
|
||||
return sp.GetRequiredService<IAuthorizationService>();
|
||||
}
|
||||
|
||||
/// <summary>An authenticated principal carrying the given role claims.</summary>
|
||||
private static ClaimsPrincipal AuthenticatedWith(params string[] roles)
|
||||
=> new(new ClaimsIdentity(
|
||||
roles.Select(r => new Claim(ClaimTypes.Role, r)),
|
||||
authenticationType: "Test"));
|
||||
|
||||
/// <summary>An unauthenticated principal (no authenticationType ⇒ IsAuthenticated=false).</summary>
|
||||
private static ClaimsPrincipal Unauthenticated() => new(new ClaimsIdentity());
|
||||
|
||||
private static async Task<bool> AllowedAsync(ClaimsPrincipal user, string policy)
|
||||
=> (await BuildAuthorizationService().AuthorizeAsync(user, resource: null, policy)).Succeeded;
|
||||
|
||||
// ── ConfigEditor: Administrator or Designer ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigEditor_denies_Viewer()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Viewer"), AdminUiPolicies.ConfigEditor)).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigEditor_denies_Operator()
|
||||
=> (await AllowedAsync(AuthenticatedWith(DevAuthRoles.Operator), AdminUiPolicies.ConfigEditor)).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigEditor_allows_Designer()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Designer"), AdminUiPolicies.ConfigEditor)).ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigEditor_allows_Administrator()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Administrator"), AdminUiPolicies.ConfigEditor)).ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigEditor_denies_unauthenticated()
|
||||
=> (await AllowedAsync(Unauthenticated(), AdminUiPolicies.ConfigEditor)).ShouldBeFalse();
|
||||
|
||||
// ── AuthenticatedRead: any authenticated user ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedRead_allows_roleless_authenticated()
|
||||
=> (await AllowedAsync(AuthenticatedWith(), AdminUiPolicies.AuthenticatedRead)).ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedRead_allows_Viewer()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Viewer"), AdminUiPolicies.AuthenticatedRead)).ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedRead_denies_unauthenticated()
|
||||
=> (await AllowedAsync(Unauthenticated(), AdminUiPolicies.AuthenticatedRead)).ShouldBeFalse();
|
||||
|
||||
// ── FleetAdmin: Administrator only (pre-existing policy, pinned here) ───────────
|
||||
|
||||
[Fact]
|
||||
public async Task FleetAdmin_allows_Administrator()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Administrator"), AdminUiPolicies.FleetAdmin)).ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public async Task FleetAdmin_denies_Designer()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Designer"), AdminUiPolicies.FleetAdmin)).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public async Task FleetAdmin_denies_Operator()
|
||||
=> (await AllowedAsync(AuthenticatedWith(DevAuthRoles.Operator), AdminUiPolicies.FleetAdmin)).ShouldBeFalse();
|
||||
|
||||
// ── DriverOperator: Operator + Administrator only (pre-existing policy, pinned) ─
|
||||
|
||||
[Fact]
|
||||
public async Task DriverOperator_allows_Operator()
|
||||
=> (await AllowedAsync(AuthenticatedWith(DevAuthRoles.Operator), AdminUiPolicies.DriverOperator)).ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public async Task DriverOperator_allows_Administrator()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Administrator"), AdminUiPolicies.DriverOperator)).ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public async Task DriverOperator_denies_Designer()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Designer"), AdminUiPolicies.DriverOperator)).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public async Task DriverOperator_denies_Viewer()
|
||||
=> (await AllowedAsync(AuthenticatedWith("Viewer"), AdminUiPolicies.DriverOperator)).ShouldBeFalse();
|
||||
|
||||
// ── Dev-rig protection: DevAuthRoles.All satisfies every policy ────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(AdminUiPolicies.FleetAdmin)]
|
||||
[InlineData(AdminUiPolicies.DriverOperator)]
|
||||
[InlineData(AdminUiPolicies.ConfigEditor)]
|
||||
[InlineData(AdminUiPolicies.AuthenticatedRead)]
|
||||
public async Task DevAuthRoles_All_satisfies_every_policy(string policy)
|
||||
=> (await AllowedAsync(AuthenticatedWith(DevAuthRoles.All), policy)).ShouldBeTrue();
|
||||
}
|
||||
Reference in New Issue
Block a user