feat(security): add ConfigEditor + AuthenticatedRead AdminUI policies behind AdminUiPolicies constants
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
"planPath": "archreview/plans/R2-05-adminui-authz-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "pending", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "pending", "blockedBy": ["T1"] },
|
||||
{ "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "pending", "blockedBy": ["T1"] },
|
||||
{ "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "pending", "blockedBy": ["T2"] },
|
||||
{ "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "completed", "blockedBy": ["T2"] },
|
||||
{ "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "pending", "blockedBy": ["T1"] },
|
||||
{ "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Names of the AdminUI authorization policies registered by <c>AddOtOpcUaAuth</c>.
|
||||
/// Single source of truth — pages, AuthorizeView blocks, imperative checks, and endpoint
|
||||
/// groups must reference these constants, never string literals (guarded by
|
||||
/// PageAuthorizationGuardTests). Policy → role mapping lives in ServiceCollectionExtensions.
|
||||
/// </summary>
|
||||
public static class AdminUiPolicies
|
||||
{
|
||||
/// <summary>Fleet-wide admin actions (RoleGrants page, certificate mutations). Roles: Administrator.</summary>
|
||||
public const string FleetAdmin = "FleetAdmin";
|
||||
|
||||
/// <summary>Live driver operations (reconnect/restart, alarm ack/shelve, live address browse). Roles: Operator, Administrator.</summary>
|
||||
public const string DriverOperator = "DriverOperator";
|
||||
|
||||
/// <summary>Config authoring: UNS/equipment/cluster/driver/ACL/namespace/script editors + deploy + script analysis. Roles: Administrator, Designer.</summary>
|
||||
public const string ConfigEditor = "ConfigEditor";
|
||||
|
||||
/// <summary>Read-only AdminUI access (dashboards, lists, live tails). Any authenticated user.</summary>
|
||||
public const string AuthenticatedRead = "AuthenticatedRead";
|
||||
}
|
||||
@@ -152,12 +152,24 @@ public static class ServiceCollectionExtensions
|
||||
// are the canonical control-plane roles: Operator (was DriverOperator) and
|
||||
// Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings
|
||||
// (e.g. "ot-driver-operator": "Operator").
|
||||
o.AddPolicy("DriverOperator", policy =>
|
||||
policy.RequireRole("Operator", "Administrator"));
|
||||
o.AddPolicy(AdminUiPolicies.DriverOperator, policy =>
|
||||
policy.RequireRole(DevAuthRoles.Operator, "Administrator"));
|
||||
|
||||
// FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages
|
||||
// such as RoleGrants. Requires the canonical Administrator role (was FleetAdmin).
|
||||
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("Administrator"));
|
||||
o.AddPolicy(AdminUiPolicies.FleetAdmin, policy => policy.RequireRole("Administrator"));
|
||||
|
||||
// ConfigEditor: may author configuration (UNS, equipment, clusters, drivers incl. live
|
||||
// ResilienceConfig, ACLs, namespaces, scripts) and trigger deploys. Same semantics the
|
||||
// Deployments/Scripts/ScriptEdit pages previously spelled as Roles="Administrator,Designer".
|
||||
o.AddPolicy(AdminUiPolicies.ConfigEditor, policy =>
|
||||
policy.RequireRole("Administrator", "Designer"));
|
||||
|
||||
// AuthenticatedRead: explicit name for "any authenticated user" so read-only pages can carry
|
||||
// a non-bare attribute and the page-authorization guard can ban bare [Authorize] outright.
|
||||
// Semantically identical to the FallbackPolicy.
|
||||
o.AddPolicy(AdminUiPolicies.AuthenticatedRead, policy =>
|
||||
policy.RequireAuthenticatedUser());
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
@@ -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