fix(adminui): gate every page with named policies — ConfigEditor on the mutating surface (R2-05, 04/C-1)

This commit is contained in:
Joseph Doherty
2026-07-13 09:56:08 -04:00
parent 183b72b7cb
commit b5bf4b73ff
40 changed files with 232 additions and 45 deletions
@@ -6,13 +6,13 @@
{ "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "completed", "blockedBy": ["T1"] }, { "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": "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": "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": "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": "pending", "blockedBy": ["T4", "T5"] }, { "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": "pending", "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": "pending", "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": "pending", "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": "pending", "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": "pending", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, { "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": "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": "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": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "pending", "blockedBy": ["T12", "T13"] },
@@ -3,7 +3,7 @@
grants in favour of fleet-wide LDAP-group → role mapping (Q4 of the AdminUI rebuild plan), so 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 this version only shows identity + the resolved fleet roles + raw LDAP groups for
troubleshooting. *@ troubleshooting. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@using System.Security.Claims @using System.Security.Claims
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -1,7 +1,7 @@
@page "/alarms-historian" @page "/alarms-historian"
@* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the @* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the
HistorianAdapterActor.GetStatus query landed in F11. *@ HistorianAdapterActor.GetStatus query landed in F11. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Akka.Actor @using Akka.Actor
@using Akka.Hosting @using Akka.Hosting
@@ -2,9 +2,8 @@
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent @* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms) AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms)
and the AB CIP ALMD bridge. *@ and the AB CIP ALMD bridge. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@@ -1,5 +1,5 @@
@page "/certificates" @page "/certificates"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using System.Security.Cryptography.X509Certificates @using System.Security.Cryptography.X509Certificates
@using Microsoft.Extensions.Configuration @using Microsoft.Extensions.Configuration
@@ -1,6 +1,6 @@
@page "/clusters/{ClusterId}/acls/new" @page "/clusters/{ClusterId}/acls/new"
@page "/clusters/{ClusterId}/acls/{NodeAclId}" @page "/clusters/{ClusterId}/acls/{NodeAclId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/acls" @page "/clusters/{ClusterId}/acls"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/audit" @page "/clusters/{ClusterId}/audit"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers" @page "/clusters/{ClusterId}/drivers"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,7 +1,7 @@
@page "/clusters/{ClusterId}/edit" @page "/clusters/{ClusterId}/edit"
@* Edit page for an existing ServerCluster. The /clusters/new route lives in NewCluster.razor; @* 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). *@ 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 @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/namespaces" @page "/clusters/{ClusterId}/namespaces"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}" @page "/clusters/{ClusterId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/redundancy" @page "/clusters/{ClusterId}/redundancy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters" @page "/clusters"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/abcip" @page "/clusters/{ClusterId}/drivers/new/abcip"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/ablegacy" @page "/clusters/{ClusterId}/drivers/new/ablegacy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -2,7 +2,7 @@
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
no registered typed page. *@ no registered typed page. *@
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}" @page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,7 +1,7 @@
@* Driver type picker — presents a card grid of registered driver types and links to the @* 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}). *@ per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
@page "/clusters/{ClusterId}/drivers/new" @page "/clusters/{ClusterId}/drivers/new"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">New driver &middot; <span class="mono">@ClusterId</span></h4> <h4 class="mb-0">New driver &middot; <span class="mono">@ClusterId</span></h4>
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/focas" @page "/clusters/{ClusterId}/drivers/new/focas"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/galaxy" @page "/clusters/{ClusterId}/drivers/new/galaxy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/modbustcp" @page "/clusters/{ClusterId}/drivers/new/modbustcp"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/opcuaclient" @page "/clusters/{ClusterId}/drivers/new/opcuaclient"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/s7" @page "/clusters/{ClusterId}/drivers/new/s7"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/twincat" @page "/clusters/{ClusterId}/drivers/new/twincat"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -3,7 +3,7 @@
@* Live-edit form pattern — one page handles both create (NamespaceId is null) and update. @* 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 RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency
conflicts surface as a toast and reload the current row. *@ conflicts surface as a toast and reload the current row. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/new" @page "/clusters/new"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -2,7 +2,7 @@
@page "/clusters/{ClusterId}/nodes/{NodeId}" @page "/clusters/{ClusterId}/nodes/{NodeId}"
@* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this @* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this
at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@ at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,4 @@
@page "/deployments" @page "/deployments"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@@ -9,7 +8,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations @using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations
@attribute [Authorize(Roles = "Administrator,Designer")] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory @inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAdminOperationsClient AdminOps @inject IAdminOperationsClient AdminOps
@@ -3,7 +3,7 @@
progress row owned by each DriverHostActor) and projects the most-recent row per node. The 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 Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied
anything yet alongside nodes that have. *@ anything yet alongside nodes that have. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@@ -1,4 +1,5 @@
@page "/" @page "/"
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
<PageTitle>OtOpcUa</PageTitle> <PageTitle>OtOpcUa</PageTitle>
@@ -4,7 +4,7 @@
cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows 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 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. *@ 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 @rendermode RenderMode.InteractiveServer
@using Akka.Actor @using Akka.Actor
@using Akka.Cluster @using Akka.Cluster
@@ -1,5 +1,5 @@
@page "/reservations" @page "/reservations"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/role-grants" @page "/role-grants"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")] @attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@@ -2,7 +2,7 @@
@page "/scripts/{ScriptId}" @page "/scripts/{ScriptId}"
@* Script CRUD. SourceHash is computed automatically from SourceCode on save so the @* Script CRUD. SourceHash is computed automatically from SourceCode on save so the
integrity check in v2's deployment pipeline doesn't require operator action. *@ 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 @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,7 +1,7 @@
@page "/script-log" @page "/script-log"
@* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from @* 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. *@ VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
@@ -1,5 +1,5 @@
@page "/scripts" @page "/scripts"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Administrator,Designer")] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -4,7 +4,7 @@
the shell + the Details tab (lifted from EquipmentModal's EditForm) + the create→edit redirect; the 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 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. *@ 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 @rendermode RenderMode.InteractiveServer
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@@ -1,5 +1,5 @@
@page "/uns" @page "/uns"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
@@ -1,3 +1,4 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@@ -7,4 +8,5 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
@using ZB.MOM.WW.OtOpcUa.Security.Auth
@using ZB.MOM.WW.Theme @using ZB.MOM.WW.Theme
@@ -0,0 +1,186 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Uns;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Authorization;
/// <summary>
/// Reflection guard over every routable AdminUI page's class-level authorization metadata.
/// This is the substitute for bUnit (the repo has no bUnit; razor <c>@attribute</c>/<c>@page</c>
/// compile to class-level attributes, so a metadata scan is authoritative and needs no rendering).
/// It is the anti-drift anchor: a new page fails until the author consciously classifies it.
/// Guard universe = <c>typeof(Routes).Assembly</c> — the exact assembly Routes.razor hands
/// <c>&lt;Router AppAssembly=…&gt;</c>, so the guard sees precisely the router's page set.
/// </summary>
public class PageAuthorizationGuardTests
{
/// <summary>
/// Target classification: every routable page → its required <see cref="AdminUiPolicies"/> policy.
/// This is the 38-page census from the R2-05 plan. <see cref="AnonymousPages"/> holds the sole
/// <c>[AllowAnonymous]</c> page. Together they must exactly cover the router's page set.
/// </summary>
private static readonly IReadOnlyDictionary<Type, string> ExpectedPolicy = new Dictionary<Type, string>
{
// ── ConfigEditor (20): the config-authoring surface (incl. live ResilienceConfig) ──
[typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor,
[typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor,
[typeof(NewCluster)] = AdminUiPolicies.ConfigEditor,
[typeof(ClusterEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(NodeEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(NamespaceEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(AclEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(DriverTypePicker)] = AdminUiPolicies.ConfigEditor,
[typeof(DriverEditRouter)] = AdminUiPolicies.ConfigEditor,
[typeof(ModbusDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(S7DriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(AbCipDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(AbLegacyDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(TwinCATDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(FocasDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(OpcUaClientDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(GalaxyDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(Deployments)] = AdminUiPolicies.ConfigEditor,
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
// ── FleetAdmin (1) ──
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) ──
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ScriptLog)] = AdminUiPolicies.AuthenticatedRead,
[typeof(AlarmsHistorian)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Account)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Certificates)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Reservations)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClustersList)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterOverview)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterAudit)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterAcls)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterNamespaces)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterDrivers)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterRedundancy)] = AdminUiPolicies.AuthenticatedRead,
};
/// <summary>The only page that must remain anonymously reachable (Admin-001 — the login form).</summary>
private static readonly IReadOnlySet<Type> AnonymousPages = new HashSet<Type> { typeof(Login) };
private static readonly string[] KnownPolicyNames =
[
AdminUiPolicies.FleetAdmin,
AdminUiPolicies.DriverOperator,
AdminUiPolicies.ConfigEditor,
AdminUiPolicies.AuthenticatedRead,
];
private static IReadOnlyList<Type> RoutablePages() =>
[.. typeof(Routes).Assembly.GetTypes()
.Where(t => t.GetCustomAttributes<RouteAttribute>(inherit: false).Any())
.OrderBy(t => t.FullName, StringComparer.Ordinal)];
private static string RouteOf(Type t) =>
string.Join(", ", t.GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template));
private static AuthorizeAttribute? Authorize(Type t) =>
t.GetCustomAttributes<AuthorizeAttribute>(inherit: false).SingleOrDefault();
private static bool IsAnonymous(Type t) =>
t.GetCustomAttributes<AllowAnonymousAttribute>(inherit: false).Any();
/// <summary>
/// Rule 1 — no bare gates. Every routable page must carry either <c>[AllowAnonymous]</c> or an
/// <c>[Authorize]</c> with a non-empty Policy and null Roles. Bans bare <c>[Authorize]</c>, the
/// <c>Roles="…"</c> idiom, and attribute-less pages.
/// </summary>
[Fact]
public void EveryRoutablePage_carriesAnExplicitNamedPolicyOrAllowAnonymous()
{
var offenders = new List<string>();
foreach (var page in RoutablePages())
{
if (IsAnonymous(page))
continue;
var auth = Authorize(page);
if (auth is null)
{
offenders.Add($"{page.FullName} ({RouteOf(page)}): no [Authorize]/[AllowAnonymous] attribute");
continue;
}
if (string.IsNullOrEmpty(auth.Policy))
offenders.Add($"{page.FullName} ({RouteOf(page)}): bare [Authorize] — no Policy");
if (!string.IsNullOrEmpty(auth.Roles))
offenders.Add($"{page.FullName} ({RouteOf(page)}): uses Roles=\"{auth.Roles}\" idiom — convert to a named policy");
}
offenders.ShouldBeEmpty(
"Pages must carry an explicit named policy (or [AllowAnonymous]). Offenders:\n" +
string.Join("\n", offenders));
}
/// <summary>
/// Rule 2 — exhaustive classification. The router's page set must equal the expected map plus the
/// anonymous allowlist, and every page's actual policy must match the expected one. A brand-new page
/// fails here until the author classifies it.
/// </summary>
[Fact]
public void EveryRoutablePage_isClassifiedAndMatchesExpectedPolicy()
{
var routable = RoutablePages().ToHashSet();
var classified = new HashSet<Type>(ExpectedPolicy.Keys);
classified.UnionWith(AnonymousPages);
var unclassified = routable.Except(classified)
.Select(t => $"{t.FullName} ({RouteOf(t)})").OrderBy(s => s).ToList();
unclassified.ShouldBeEmpty(
"Routable pages missing from the guard's expected map — classify each in ExpectedPolicy or AnonymousPages:\n" +
string.Join("\n", unclassified));
var stale = classified.Except(routable)
.Select(t => t.FullName ?? t.Name).OrderBy(s => s).ToList();
stale.ShouldBeEmpty(
"Guard expected-map entries that are no longer routable pages — remove them:\n" +
string.Join("\n", stale));
var mismatches = new List<string>();
foreach (var (page, expected) in ExpectedPolicy)
{
var actual = Authorize(page)?.Policy;
if (actual != expected)
mismatches.Add($"{page.FullName} ({RouteOf(page)}): expected policy '{expected}', got '{actual ?? "<none>"}'");
}
mismatches.ShouldBeEmpty(
"Pages whose actual policy differs from the expected classification:\n" +
string.Join("\n", mismatches));
}
/// <summary>Rule 3 — every Policy value in use is one of the four known <see cref="AdminUiPolicies"/> constants.</summary>
[Fact]
public void EveryPolicyNameInUse_isAKnownAdminUiPolicy()
{
var unknown = new List<string>();
foreach (var page in RoutablePages())
{
var policy = Authorize(page)?.Policy;
if (!string.IsNullOrEmpty(policy) && !KnownPolicyNames.Contains(policy))
unknown.Add($"{page.FullName} ({RouteOf(page)}): unknown policy '{policy}'");
}
unknown.ShouldBeEmpty(
"Pages referencing a policy name not defined in AdminUiPolicies:\n" +
string.Join("\n", unknown));
}
}