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));
+ }
+}