From b5bf4b73ffa0137e3a637b9ee1d6f05b4c336abe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:56:08 -0400 Subject: [PATCH] =?UTF-8?q?fix(adminui):=20gate=20every=20page=20with=20na?= =?UTF-8?q?med=20policies=20=E2=80=94=20ConfigEditor=20on=20the=20mutating?= =?UTF-8?q?=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)); + } +}