diff --git a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json index 3fa64da1..85d73959 100644 --- a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json +++ b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json @@ -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"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs new file mode 100644 index 00000000..01222682 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.OtOpcUa.Security.Auth; + +/// +/// Names of the AdminUI authorization policies registered by AddOtOpcUaAuth. +/// 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. +/// +public static class AdminUiPolicies +{ + /// Fleet-wide admin actions (RoleGrants page, certificate mutations). Roles: Administrator. + public const string FleetAdmin = "FleetAdmin"; + + /// Live driver operations (reconnect/restart, alarm ack/shelve, live address browse). Roles: Operator, Administrator. + public const string DriverOperator = "DriverOperator"; + + /// Config authoring: UNS/equipment/cluster/driver/ACL/namespace/script editors + deploy + script analysis. Roles: Administrator, Designer. + public const string ConfigEditor = "ConfigEditor"; + + /// Read-only AdminUI access (dashboards, lists, live tails). Any authenticated user. + public const string AuthenticatedRead = "AuthenticatedRead"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index b01744e2..1a15e0d5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -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; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs new file mode 100644 index 00000000..423617b3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs @@ -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; + +/// +/// Pins the semantics of the four AdminUI authorization policies registered by +/// AddOtOpcUaAuth. Mirrors provider +/// construction (in-memory config, real registration, resolve ). +/// 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. +/// +public class AdminUiPoliciesTests +{ + private static IAuthorizationService BuildAuthorizationService() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Security:Auth:DisableLogin"] = "false", + }).Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOtOpcUaAuth(config); + + var sp = services.BuildServiceProvider(); + return sp.GetRequiredService(); + } + + /// An authenticated principal carrying the given role claims. + private static ClaimsPrincipal AuthenticatedWith(params string[] roles) + => new(new ClaimsIdentity( + roles.Select(r => new Claim(ClaimTypes.Role, r)), + authenticationType: "Test")); + + /// An unauthenticated principal (no authenticationType ⇒ IsAuthenticated=false). + private static ClaimsPrincipal Unauthenticated() => new(new ClaimsIdentity()); + + private static async Task 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(); +}