From 183b72b7cb9545d5bd9a5a06533050081c786578 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:52:13 -0400 Subject: [PATCH 1/5] feat(security): add ConfigEditor + AuthenticatedRead AdminUI policies behind AdminUiPolicies constants --- .../R2-05-adminui-authz-plan.md.tasks.json | 8 +- .../Auth/AdminUiPolicies.cs | 22 ++++ .../ServiceCollectionExtensions.cs | 18 ++- .../AdminUiPoliciesTests.cs | 124 ++++++++++++++++++ 4 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs 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(); +} From b5bf4b73ffa0137e3a637b9ee1d6f05b4c336abe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:56:08 -0400 Subject: [PATCH 2/5] =?UTF-8?q?fix(adminui):=20gate=20every=20page=20with?= =?UTF-8?q?=20named=20policies=20=E2=80=94=20ConfigEditor=20on=20the=20mut?= =?UTF-8?q?ating=20surface=20(R2-05,=2004/C-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R2-05-adminui-authz-plan.md.tasks.json | 14 +- .../Components/Pages/Account.razor | 2 +- .../Components/Pages/AlarmsHistorian.razor | 2 +- .../Components/Pages/Alerts.razor | 3 +- .../Components/Pages/Certificates.razor | 2 +- .../Components/Pages/Clusters/AclEdit.razor | 2 +- .../Pages/Clusters/ClusterAcls.razor | 2 +- .../Pages/Clusters/ClusterAudit.razor | 2 +- .../Pages/Clusters/ClusterDrivers.razor | 2 +- .../Pages/Clusters/ClusterEdit.razor | 2 +- .../Pages/Clusters/ClusterNamespaces.razor | 2 +- .../Pages/Clusters/ClusterOverview.razor | 2 +- .../Pages/Clusters/ClusterRedundancy.razor | 2 +- .../Pages/Clusters/ClustersList.razor | 2 +- .../Clusters/Drivers/AbCipDriverPage.razor | 2 +- .../Clusters/Drivers/AbLegacyDriverPage.razor | 2 +- .../Clusters/Drivers/DriverEditRouter.razor | 2 +- .../Clusters/Drivers/DriverTypePicker.razor | 2 +- .../Clusters/Drivers/FocasDriverPage.razor | 2 +- .../Clusters/Drivers/GalaxyDriverPage.razor | 2 +- .../Clusters/Drivers/ModbusDriverPage.razor | 2 +- .../Drivers/OpcUaClientDriverPage.razor | 2 +- .../Pages/Clusters/Drivers/S7DriverPage.razor | 2 +- .../Clusters/Drivers/TwinCATDriverPage.razor | 2 +- .../Pages/Clusters/NamespaceEdit.razor | 2 +- .../Pages/Clusters/NewCluster.razor | 2 +- .../Components/Pages/Clusters/NodeEdit.razor | 2 +- .../Components/Pages/Deployments.razor | 3 +- .../Components/Pages/Fleet.razor | 2 +- .../Components/Pages/Home.razor | 1 + .../Components/Pages/Hosts.razor | 2 +- .../Components/Pages/Reservations.razor | 2 +- .../Components/Pages/RoleGrants.razor | 2 +- .../Components/Pages/ScriptEdit.razor | 2 +- .../Components/Pages/ScriptLog.razor | 2 +- .../Components/Pages/Scripts.razor | 2 +- .../Components/Pages/Uns/EquipmentPage.razor | 2 +- .../Components/Pages/Uns/GlobalUns.razor | 2 +- .../ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor | 2 + .../PageAuthorizationGuardTests.cs | 186 ++++++++++++++++++ 40 files changed, 232 insertions(+), 45 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs 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 85d73959..d430662c 100644 --- a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json +++ b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json @@ -6,13 +6,13 @@ { "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"] }, - { "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "pending", "blockedBy": ["T4", "T5"] }, - { "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "pending", "blockedBy": ["T4", "T5"] }, - { "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "pending", "blockedBy": ["T4", "T5"] }, - { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "pending", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, + { "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "completed", "blockedBy": ["T1"] }, + { "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "completed", "blockedBy": ["T4", "T5"] }, + { "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "completed", "blockedBy": ["T4", "T5"] }, + { "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "completed", "blockedBy": ["T4", "T5"] }, + { "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] }, + { "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] }, + { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "pending", "blockedBy": ["T11"] }, { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "pending", "blockedBy": ["T11"] }, { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "pending", "blockedBy": ["T12", "T13"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor index 7bf82ea7..dc4b2b07 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor @@ -3,7 +3,7 @@ grants in favour of fleet-wide LDAP-group → role mapping (Q4 of the AdminUI rebuild plan), so this version only shows identity + the resolved fleet roles + raw LDAP groups for troubleshooting. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @using System.Security.Claims
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor index cc3f2d45..db03988c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor @@ -1,7 +1,7 @@ @page "/alarms-historian" @* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the HistorianAdapterActor.GetStatus query landed in F11. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Akka.Actor @using Akka.Hosting diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor index b7927f74..b2a649b6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor @@ -2,9 +2,8 @@ @* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms) and the AB CIP ALMD bridge. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer -@using Microsoft.AspNetCore.Authorization @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor index e32f3cae..641e1337 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor @@ -1,5 +1,5 @@ @page "/certificates" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using System.Security.Cryptography.X509Certificates @using Microsoft.Extensions.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index 74856fe2..e61d0e9d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -1,6 +1,6 @@ @page "/clusters/{ClusterId}/acls/new" @page "/clusters/{ClusterId}/acls/{NodeAclId}" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor index 44fb3e4f..2fd6fdb2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/acls" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor index 55161030..84e4f849 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/audit" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor index 132867ed..2aa283c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor index a1598195..5f1fa180 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor @@ -1,7 +1,7 @@ @page "/clusters/{ClusterId}/edit" @* Edit page for an existing ServerCluster. The /clusters/new route lives in NewCluster.razor; this page handles only the update case so the form can disable ClusterId (immutable). *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor index e8b036fc..3e9110ef 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/namespaces" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor index d5d7c281..d1635e54 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor index e5c00287..cd9af9aa 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/redundancy" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor index 31415f20..d76b6070 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor @@ -1,5 +1,5 @@ @page "/clusters" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor index d0e3ddb5..69aa461e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers/new/abcip" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor index 61abdeae..fd582119 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers/new/ablegacy" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor index 5cccb3d2..97d25333 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor @@ -2,7 +2,7 @@ via using _componentMap. Shows an error panel when the driver type has no registered typed page. *@ @page "/clusters/{ClusterId}/drivers/{DriverInstanceId}" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor index c678a61f..6b81bfc5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor @@ -1,7 +1,7 @@ @* Driver type picker — presents a card grid of registered driver types and links to the per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@ @page "/clusters/{ClusterId}/drivers/new" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]

New driver · @ClusterId

diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor index a56855cf..3f174a88 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers/new/focas" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor index 7a9c7f91..3620f4da 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers/new/galaxy" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor index c45a5768..489ab9c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers/new/modbustcp" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor index 54773d44..28d10777 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers/new/opcuaclient" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor index c017a90d..994e2c97 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers/new/s7" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor index 469b08bf..eb32540a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor @@ -1,5 +1,5 @@ @page "/clusters/{ClusterId}/drivers/new/twincat" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor index 2b09d13f..5193e508 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor @@ -3,7 +3,7 @@ @* Live-edit form pattern — one page handles both create (NamespaceId is null) and update. RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency conflicts surface as a toast and reload the current row. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor index 647cdc50..4b9a89a5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor @@ -1,5 +1,5 @@ @page "/clusters/new" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor index 4be4b61d..2c1fef76 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor @@ -2,7 +2,7 @@ @page "/clusters/{ClusterId}/nodes/{NodeId}" @* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor index b4281789..ec4dffec 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor @@ -1,5 +1,4 @@ @page "/deployments" -@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @@ -9,7 +8,7 @@ @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations -@attribute [Authorize(Roles = "Administrator,Designer")] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @inject IDbContextFactory DbFactory @inject IAdminOperationsClient AdminOps diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor index 4f669ee8..b620daa8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor @@ -3,7 +3,7 @@ progress row owned by each DriverHostActor) and projects the most-recent row per node. The Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied anything yet alongside nodes that have. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Home.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Home.razor index 7c8804d4..60375c58 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Home.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Home.razor @@ -1,4 +1,5 @@ @page "/" +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] OtOpcUa diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 069df324..107b1bd6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -4,7 +4,7 @@ cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows are cluster-scoped (keyed per driver instance across the cluster, not per member). The section reads the in-process driver-health snapshot store directly + reloads its config from the ConfigDB. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Akka.Actor @using Akka.Cluster diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor index 86295ccc..6f636d87 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor @@ -1,5 +1,5 @@ @page "/reservations" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor index 8d32e74d..47b272d3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor @@ -1,5 +1,5 @@ @page "/role-grants" -@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")] +@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)] @rendermode RenderMode.InteractiveServer @using Microsoft.Extensions.Options @using ZB.MOM.WW.OtOpcUa.Configuration.Entities diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor index 8dc24919..466b67f8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -2,7 +2,7 @@ @page "/scripts/{ScriptId}" @* Script CRUD. SourceHash is computed automatically from SourceCode on save so the integrity check in v2's deployment pipeline doesn't require operator action. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Administrator,Designer")] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor index b603d9c4..d7c4f3e9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor @@ -1,7 +1,7 @@ @page "/script-log" @* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor index 598110bb..cdc19cb6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor @@ -1,5 +1,5 @@ @page "/scripts" -@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Administrator,Designer")] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor index 636d2ced..b1711eeb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor @@ -4,7 +4,7 @@ the shell + the Details tab (lifted from EquipmentModal's EditForm) + the create→edit redirect; the Tags / Virtual Tags / Alarms tabs render placeholders wired in later tasks. On a successful create the page redirects to /uns/equipment/{newId} so the other tabs (disabled while new) become available. *@ -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor index 76e03263..a12c11c4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor @@ -1,5 +1,5 @@ @page "/uns" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor index ba28bd70..c696a484 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor @@ -1,3 +1,4 @@ +@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Forms @@ -7,4 +8,5 @@ @using Microsoft.JSInterop @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout +@using ZB.MOM.WW.OtOpcUa.Security.Auth @using ZB.MOM.WW.Theme diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs new file mode 100644 index 00000000..4e12e47b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs @@ -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; + +/// +/// Reflection guard over every routable AdminUI page's class-level authorization metadata. +/// This is the substitute for bUnit (the repo has no bUnit; razor @attribute/@page +/// 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 = typeof(Routes).Assembly — the exact assembly Routes.razor hands +/// <Router AppAssembly=…>, so the guard sees precisely the router's page set. +/// +public class PageAuthorizationGuardTests +{ + /// + /// Target classification: every routable page → its required policy. + /// This is the 38-page census from the R2-05 plan. holds the sole + /// [AllowAnonymous] page. Together they must exactly cover the router's page set. + /// + private static readonly IReadOnlyDictionary ExpectedPolicy = new Dictionary + { + // ── 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, + }; + + /// The only page that must remain anonymously reachable (Admin-001 — the login form). + private static readonly IReadOnlySet AnonymousPages = new HashSet { typeof(Login) }; + + private static readonly string[] KnownPolicyNames = + [ + AdminUiPolicies.FleetAdmin, + AdminUiPolicies.DriverOperator, + AdminUiPolicies.ConfigEditor, + AdminUiPolicies.AuthenticatedRead, + ]; + + private static IReadOnlyList RoutablePages() => + [.. typeof(Routes).Assembly.GetTypes() + .Where(t => t.GetCustomAttributes(inherit: false).Any()) + .OrderBy(t => t.FullName, StringComparer.Ordinal)]; + + private static string RouteOf(Type t) => + string.Join(", ", t.GetCustomAttributes(inherit: false).Select(r => r.Template)); + + private static AuthorizeAttribute? Authorize(Type t) => + t.GetCustomAttributes(inherit: false).SingleOrDefault(); + + private static bool IsAnonymous(Type t) => + t.GetCustomAttributes(inherit: false).Any(); + + /// + /// Rule 1 — no bare gates. Every routable page must carry either [AllowAnonymous] or an + /// [Authorize] with a non-empty Policy and null Roles. Bans bare [Authorize], the + /// Roles="…" idiom, and attribute-less pages. + /// + [Fact] + public void EveryRoutablePage_carriesAnExplicitNamedPolicyOrAllowAnonymous() + { + var offenders = new List(); + 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)); + } + + /// + /// 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. + /// + [Fact] + public void EveryRoutablePage_isClassifiedAndMatchesExpectedPolicy() + { + var routable = RoutablePages().ToHashSet(); + var classified = new HashSet(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(); + 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 ?? ""}'"); + } + + mismatches.ShouldBeEmpty( + "Pages whose actual policy differs from the expected classification:\n" + + string.Join("\n", mismatches)); + } + + /// Rule 3 — every Policy value in use is one of the four known constants. + [Fact] + public void EveryPolicyNameInUse_isAKnownAdminUiPolicy() + { + var unknown = new List(); + 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)); + } +} From aac32854687d02376e519a54065014d6837ff2e0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:58:42 -0400 Subject: [PATCH 3/5] refactor(adminui): replace authorization string literals with AdminUiPolicies constants --- archreview/plans/R2-05-adminui-authz-plan.md.tasks.json | 2 +- .../Components/Pages/Alerts.razor | 2 +- .../Components/Pages/Certificates.razor | 4 ++-- .../Components/Shared/Drivers/DriverStatusPanel.razor | 2 +- .../Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor | 2 +- .../Drivers/Pickers/OpcUaClientAddressPickerBody.razor | 2 +- .../ScriptAnalysis/ScriptAnalysisEndpoints.cs | 8 ++++---- 7 files changed, 11 insertions(+), 11 deletions(-) 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 d430662c..6b04a7dd 100644 --- a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json +++ b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json @@ -13,7 +13,7 @@ { "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] }, { "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] }, { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, - { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "pending", "blockedBy": ["T11"] }, + { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] }, { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "pending", "blockedBy": ["T11"] }, { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "pending", "blockedBy": ["T12", "T13"] }, { "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "pending", "blockedBy": ["T14"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor index b2a649b6..a9c77a6c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor @@ -161,7 +161,7 @@ else // permitted users. The username is re-read at click time (GetCurrentUserNameAsync) so a // mid-session token refresh lands in the published command + audit accurately. var auth = await AuthState.GetAuthenticationStateAsync(); - var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); _canOperate = authResult.Succeeded; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor index 641e1337..887bf051 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor @@ -87,7 +87,7 @@ else @if (store.Kind is StoreKind.Trusted or StoreKind.Rejected) { - + @if (store.Kind == StoreKind.Rejected) { @@ -183,7 +183,7 @@ else // Defense-in-depth: the action buttons are FleetAdmin-gated in markup, but this handler // runs on the server circuit — re-check the policy before mutating the trust store. var authState = await AuthState.GetAuthenticationStateAsync(); - if (!(await AuthorizationService.AuthorizeAsync(authState.User, null, "FleetAdmin")).Succeeded) + if (!(await AuthorizationService.AuthorizeAsync(authState.User, null, AdminUiPolicies.FleetAdmin)).Succeeded) { _statusError = true; _statusMsg = "Unauthorized — FleetAdmin required."; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor index d2d2f2c3..e069aab0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor @@ -163,7 +163,7 @@ // The username for audit logging is re-read at button-click time (not captured here) // so token-refreshes mid-session land in audit entries accurately. var auth = await AuthState.GetAuthenticationStateAsync(); - var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); _canOperate = authResult.Succeeded; if (!Enabled) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor index 5aa73aeb..b6a0463b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor @@ -121,7 +121,7 @@ protected override async Task OnInitializedAsync() { var auth = await AuthState.GetAuthenticationStateAsync(); - var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); _canOperate = authResult.Succeeded; _built = Build(); await CurrentAddressChanged.InvokeAsync(_built); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor index a00ef2de..8d8d0c58 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor @@ -72,7 +72,7 @@ protected override async Task OnInitializedAsync() { var auth = await AuthState.GetAuthenticationStateAsync(); - var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); _canOperate = authResult.Succeeded; _built = _nodeId; await CurrentAddressChanged.InvokeAsync(_built); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index 7c801db9..7fec4785 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -1,21 +1,21 @@ -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.OtOpcUa.Security.Auth; namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; public static class ScriptAnalysisEndpoints { - /// Maps the /api/script-analysis endpoint group (diagnostics, completions, hover, signature help, format), gated to the Administrator/Designer roles. + /// Maps the /api/script-analysis endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer). /// The route builder to map the endpoints onto. /// The same route builder, for chaining. public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints) { - // Require Administrator or Designer — matches the Script page gate and the /deployments gate. + // ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate. var group = endpoints.MapGroup("/api/script-analysis") - .RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator,Designer" }); + .RequireAuthorization(AdminUiPolicies.ConfigEditor); group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r))); From dad783514af144af3a31b0a042802e9588bbdd43 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:01:52 -0400 Subject: [PATCH 4/5] docs(security): document AdminUI policy tiers (ConfigEditor/AuthenticatedRead) + R2-05 status --- CLAUDE.md | 5 +++-- .../plans/R2-05-adminui-authz-plan.md.tasks.json | 2 +- archreview/plans/STATUS.md | 6 ++++++ docs/security.md | 15 ++++++++++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e181aa94..f68ec1ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,8 +220,9 @@ help, document formatting, and tag-path completions inside `ctx.GetTag("…")` / runtime publish gate uses, so what the editor accepts/rejects matches publish exactly. The backend lives in `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/` (six minimal-API -endpoints under `/api/script-analysis/*`, gated to the `Administrator,Designer` -roles via `RequireAuthorization` in `ScriptAnalysisEndpoints.MapScriptAnalysis`). +endpoints under `/api/script-analysis/*`, gated by the `ConfigEditor` policy +(Administrator or Designer) via `RequireAuthorization` in +`ScriptAnalysisEndpoints.MapScriptAnalysisEndpoints`). See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` token for equipment-relative tag paths that resolve per-equipment at deploy time — see the "Equipment-relative tag paths" section in `docs/ScriptEditor.md`. ## Scripted Alarm Ack/Shelve 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 6b04a7dd..c2d49094 100644 --- a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json +++ b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json @@ -14,7 +14,7 @@ { "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] }, { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "pending", "blockedBy": ["T11"] }, + { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "completed", "blockedBy": ["T11"] }, { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "pending", "blockedBy": ["T12", "T13"] }, { "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "pending", "blockedBy": ["T14"] }, { "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "pending", "blockedBy": ["T15"] } diff --git a/archreview/plans/STATUS.md b/archreview/plans/STATUS.md index 0271f9e9..ba2cfd11 100644 --- a/archreview/plans/STATUS.md +++ b/archreview/plans/STATUS.md @@ -150,3 +150,9 @@ surface), 03/S2/S3 block-bridging, 03/P1 surgical adds (guard prerequisite in pl per-plan task counts, effort, and suggested execution order: [`00-INDEX.md`](00-INDEX.md) §"Round 2 plans". 193 bite-sized TDD tasks total, ~18–24 dev-days end-to-end; R2-01/02/03 (the re-review's new-and-sharp items) are ~2 days combined. + +### Round-2 remediation — in progress + +| Plan | Finding | Status | Branch @ commit | Notes | +|---|---|---|---|---| +| **R2-05 — AdminUI authorization** | 04/C-1 (High) | ✅ code-complete (offline); live pass deferred | `r2/05-adminui-authz` | One authz idiom: all 38 routable pages carry an explicit named-policy `@attribute` via new `AdminUiPolicies` constants (`Security/Auth/`). New `ConfigEditor` (Administrator+Designer) gates the 20-page config-authoring surface incl. the 8 driver pages that author live `ResilienceConfig` + `/api/script-analysis/*`; new `AuthenticatedRead` on the 16 read pages; `FleetAdmin`/`DriverOperator` unchanged (literals→constants). Anti-drift guard `PageAuthorizationGuardTests` (bans bare `[Authorize]`/`Roles=`/unknown-policy/unclassified) + `AdminUiPoliciesTests` semantics matrix. **Verification corrections folded in:** Reservations + ClusterRedundancy are read-only (→`AuthenticatedRead`, not `ConfigEditor`); Home carried no attribute at all (now `AuthenticatedRead`). Live `/run` positive pass deferred — docker-dev `DisableLogin=true` auto-authenticates as full-access admin, so it can only regression-check over-gating, not observe deny; the negative proof is CI-side (both guard tests). | diff --git a/docs/security.md b/docs/security.md index 7386632f..9df38061 100644 --- a/docs/security.md +++ b/docs/security.md @@ -295,7 +295,20 @@ The `AdminRole` enum (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole. `DriverOperator` is an **authorization policy name** (kept stable), not an `AdminRole` member. It gates **Reconnect** / **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel` and requires the canonical role `Operator` or `Administrator` (`policy.RequireRole("Operator", "Administrator")` in `AddAuthorization`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). `Operator` is an appsettings-only string role (not an `AdminRole` member); map an LDAP group to it via `GroupToRole`, e.g. `"ot-driver-operator": "Operator"`. The `FleetAdmin` policy requires the `Administrator` role. -In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`), which also installs a `FallbackPolicy` that requires an authenticated user. Razor pages gate inline with the canonical role names, e.g. `@attribute [Authorize(Roles = "Administrator,Designer")]`. Nav-menu sections hide via ``. +#### AdminUI page authorization policies (R2-05) + +Every routable AdminUI page carries an explicit **named-policy** `@attribute [Authorize(Policy = …)]` — there are no bare `[Authorize]` (authenticated-only) config pages and no per-page `Roles="…"` strings. The policy names are constants on `AdminUiPolicies` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs`), the single source of truth referenced by pages, `AuthorizeView` blocks, imperative `IAuthorizationService.AuthorizeAsync` checks, and endpoint groups (never string literals). All four are registered by `AddOtOpcUaAuth`: + +| Policy | Satisfied by roles | Gates | +|---|---|---| +| `AdminUiPolicies.ConfigEditor` | `Administrator`, `Designer` | The config-authoring surface: `/uns`, equipment, cluster/node/namespace/ACL editors, all 8 driver pages (which author live retry/breaker `ResilienceConfig`), Deployments, Scripts, ScriptEdit, and the `/api/script-analysis/*` endpoint group. | +| `AdminUiPolicies.FleetAdmin` | `Administrator` | `RoleGrants` page; the `Certificates` Trust/Untrust/Delete actions (`AuthorizeView` + server-side re-check). | +| `AdminUiPolicies.DriverOperator` | `Operator`, `Administrator` | Imperative live-ops actions only (Alerts ack/shelve, DriverStatusPanel reconnect/restart, live address pickers). | +| `AdminUiPolicies.AuthenticatedRead` | any authenticated user | The read-only dashboards, lists, and live tails (Home, Fleet, Hosts, Alerts, Certificates, cluster read views, etc.). Semantically identical to the `FallbackPolicy`; named so read pages carry a non-bare attribute. | + +`Login` is the only `[AllowAnonymous]` page (Admin-001). A reflection guard — `PageAuthorizationGuardTests` (`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs`) — enumerates every routable type in the AdminUI assembly and fails the build if any page carries a bare `[Authorize]`, a `Roles="…"` idiom, an unknown policy name, or is missing from the classification map. This is the anti-drift anchor (the repo has no bUnit): a new page fails the test until its author consciously classifies it. `AdminUiPoliciesTests` (`tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs`) pins each policy's role semantics. + +In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`), which also installs a `FallbackPolicy` that requires an authenticated user. Razor pages gate inline with the named-policy attribute above, e.g. `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]`. Nav-menu sections hide via ``. ### Role grant source From 6c1f05cba95563b7fd3f426d8c596e5b7b86343c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:07:27 -0400 Subject: [PATCH 5/5] docs(r2-05): flip 04/C-1 to FIXED, record execution deviations + task status --- archreview/04-adminui.md | 2 +- archreview/plans/R2-05-adminui-authz-plan.md | 36 +++++++++++++++++++ .../R2-05-adminui-authz-plan.md.tasks.json | 6 ++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/archreview/04-adminui.md b/archreview/04-adminui.md index a94e689a..9b790c74 100644 --- a/archreview/04-adminui.md +++ b/archreview/04-adminui.md @@ -24,7 +24,7 @@ | P-4 (Low) | `Deployments` runs a full config snapshot + hash per page load for the drift badge | **STILL OPEN** — `Deployments.razor:98` `SnapshotAndFlattenAsync` | | P-5 (Low) | `Alerts` rows lack `@key` while prepending; no `` (bounded by design) | **STILL OPEN** — `Alerts.razor:63–70` `` without `@key` | | P-6 (Low) | `inlay-hints` round-trips for a documented no-op | **STILL OPEN** — `monaco-init.js:99–117` provider active; `ScriptAnalysisService.cs:415` returns empty | -| C-1 (High) | Three authorization idioms; the largest mutating surface is bare `[Authorize]` | **STILL OPEN** — re-enumerated every page attribute: only `Deployments`/`Scripts`/`ScriptEdit` carry `Roles="Administrator,Designer"`, only `RoleGrants` carries `Policy="FleetAdmin"`; `GlobalUns`, `EquipmentPage`, all `Clusters/*` editors, all 8 driver pages, and `Reservations` remain bare `[Authorize]`. No remediation branch targeted this (STATUS.md action-list item 5, unscheduled) | +| C-1 (High) | Three authorization idioms; the largest mutating surface is bare `[Authorize]` | **FIXED (R2-05, branch `r2/05-adminui-authz`)** — one idiom now: all 38 routable pages carry an explicit named-policy `@attribute` via new `AdminUiPolicies` constants. New `ConfigEditor` (Administrator+Designer) gates the 20-page config-authoring surface incl. all 8 driver pages (live `ResilienceConfig`) + `/api/script-analysis/*`; new `AuthenticatedRead` on the 16 read pages; `FleetAdmin`/`DriverOperator` literals→constants (unchanged semantics). Anti-drift reflection guard `PageAuthorizationGuardTests` + `AdminUiPoliciesTests` semantics matrix (both green). **Two classification corrections vs. this row:** `Reservations` and `ClusterRedundancy` are read-only (→`AuthenticatedRead`, not `ConfigEditor`); `Home` carried no attribute at all (now `AuthenticatedRead`). Offline gate green; docker-dev live positive/regression pass deferred (auto-admin can't observe deny). | | C-2 (Med) | CLAUDE.md said ScriptAnalysis was `FleetAdmin`-gated; code uses `Roles="Administrator,Designer"` | **FIXED** — docs branch `9fadead6` (merged to master via `b67bd9e8`) corrected CLAUDE.md, which now reads "gated to the `Administrator,Designer` roles via `RequireAuthorization`" (CLAUDE.md:223); code unchanged and still correct | | C-3 (Good) | Driver-typed tag-editor pattern complete and consistent | **STILL HOLDS** — map/validator/editors unchanged | | C-4 (Good) | Numeric-enum serialization bug class closed and test-pinned | **STILL HOLDS** — pages + `*FormSerializationTests` unchanged | diff --git a/archreview/plans/R2-05-adminui-authz-plan.md b/archreview/plans/R2-05-adminui-authz-plan.md index 22cc3dce..55836601 100644 --- a/archreview/plans/R2-05-adminui-authz-plan.md +++ b/archreview/plans/R2-05-adminui-authz-plan.md @@ -573,3 +573,39 @@ record the two verification corrections (Reservations/ClusterRedundancy read-onl --- **Total: 16 tasks · overall effort Small-Medium (~2.5–4 h including the live pass).** + +--- + +## Execution deviations (R2-05) + +Executed 2026-07-13 on branch `r2/05-adminui-authz` (off master `1676c8f4`, not `f6eaa267` — the plan's +stated base; the two commits between are docs-only merges with zero AdminUI source change, so the census +re-verified identical). Commits: `183b72b7` (T1/2/4) · `b5bf4b73` (T3/5–11, the gate) · `aac32854` (T12 +literals) · `dad78351` (T13 docs) · plus this bookkeeping commit (T16). + +- **DriverOperator role string uses `DevAuthRoles.Operator` constant, not the `"Operator"` literal.** In + `ServiceCollectionExtensions.cs` the `RequireRole("Operator", …)` now reads `RequireRole(DevAuthRoles.Operator, …)`. + Reversible, zero behavior change (same string) — small consistency win beyond the plan's letter, which only + called for switching the *policy name* to a constant. `"Administrator"`/`"Designer"` role strings kept literal + (no constant exists for them; out of scope). +- **Guard test: two page types fully-qualified.** `typeof(Hosts)` and `typeof(Certificates)` collide with + sibling namespaces (`…Pages.Hosts`, a folder namespace exists), so those two entries use + `typeof(global::…Components.Pages.Hosts)` / `…Certificates`. The other 36 resolve via `@using`. No + `` was needed — `RouteAttribute`/`AuthorizeAttribute`/`AllowAnonymousAttribute` all + resolved transitively through the AdminUI RCL reference (the plan flagged this as a possible add). +- **`MapScriptAnalysis` → `MapScriptAnalysisEndpoints`.** The plan (and the old CLAUDE.md sentence) named the + method `MapScriptAnalysis`; the actual method is `MapScriptAnalysisEndpoints`. Corrected the doc references + to the real name while converging its literal to `AdminUiPolicies.ConfigEditor`. +- **T14 full-solution sweep run once, then constrained off.** A single `dotnet test ZB.MOM.WW.OtOpcUa.slnx` + ran before the controller's mid-task constraint (no whole-solution / no `*.IntegrationTests` — heavy suites + leak ~16 GB). All failures were triaged as pre-existing/environmental and **outside the R2-05 diff**: + Core.Abstractions.Tests `InterfaceIndependence` flags pre-existing `Historian.*` namespace types; a Runtime + historian-retry timing test; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host + IntegrationTests need Docker fixtures. The authoritative gate is the impacted unit suites — **AdminUI.Tests + 517/517 and Security.Tests 80/80, both green.** The sweep will not be re-run per the constraint. +- **T15 live `/run` deferred-live.** Marked `deferred-live` ("docker-dev live authz /run; serial live pass"). + Two reasons: (1) it is a heavy integration/browser-drive pass the controller runs serialized; (2) docker-dev + `DisableLogin=true` auto-authenticates as a full-access admin, so it can only regression-check over-gating, + never observe a role deny — the negative/deny proof is entirely CI-side (T2 + T3, green). +- **T16 does not merge.** Executor scope forbids merge/push/PR; the branch is left ready. Bookkeeping (04/C-1 + row flipped to FIXED, the two read-only classification corrections recorded, STATUS.md R2-05 row) is done. 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 c2d49094..58d12f0e 100644 --- a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json +++ b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json @@ -15,8 +15,8 @@ { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] }, { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "pending", "blockedBy": ["T12", "T13"] }, - { "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "pending", "blockedBy": ["T14"] }, - { "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "pending", "blockedBy": ["T15"] } + { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "completed", "blockedBy": ["T12", "T13"], "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." }, + { "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "deferred-live", "blockedBy": ["T14"], "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate — controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin)." }, + { "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "completed", "blockedBy": ["T15"] } ] }