44 KiB
R2-05 — AdminUI Authorization Gating: Design + Implementation Plan
Status (2026-07-12): not started. This is round-2 remediation item R2-05 — action item #5 in
archreview/00-OVERALL.md("Gate AdminUI mutating pages with real policies … Impact: High"), carried High from round 1 (04/C-1, unscheduled in the first remediation wave). Supersedes/extends §1 (C-1) of the round-104-adminui-plan.md— that section was re-verified atf6eaa267and itsConfigEditor-policy + reflection-guard design is retained, refreshed with two verification corrections (Reservations and ClusterRedundancy are read-only; Home carries no attribute at all) and extended to a single-idiom end state enforceable by the guard.
- Source report:
archreview/04-adminui.md§C-1 (re-review 2026-07-12; STILL OPEN, stakes rose — the ungated driver pages now author production-live retry/breakerResilienceConfigsince #10bacea1a4/ #1375403caamerged) - Verified against tree at: master
f6eaa267(clean; zero AdminUI source changes since the9cad9ed0round-1 baseline) - Scope: page-level
@attribute [Authorize…]acrosssrc/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/, policy definitions insrc/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs, the imperative /AuthorizeView/ endpoint-group literals that reference policies, and the new guard tests. - Out of scope: OPC UA data-plane authorization (independent LDAP path), the SignalR hubs (all
[Authorize], read-only feeds for out-of-process clients — unchanged),DeployApiEndpoints(AllowAnonymous+ self-gatedX-Api-Key, already correct and test-pinned), per-cluster permissions (maintainer posture: roles are global by design; simplest authz that works).
Verification summary
Re-verified 2026-07-12 by exhaustive enumeration (grep -rn "\[Authorize" --include="*.razor" + grep -rn "@page"
over Components/Pages/, plus every imperative AuthorizeAsync/AuthorizeView/RequireAuthorization site).
Finding CONFIRMED at f6eaa267, with two corrections and one addition (flagged ⚠ below).
Registered policies today (Security/ServiceCollectionExtensions.cs:143-161): FallbackPolicy =
RequireAuthenticatedUser (cookie scheme); "DriverOperator" = RequireRole("Operator","Administrator") (:155);
"FleetAdmin" = RequireRole("Administrator") (:160). All names are string literals — no constants type exists.
Roles: AdminRole enum = Viewer / Designer / Administrator (Configuration/Enums/AdminRole.cs); Operator
is appsettings-only (Security/Auth/DevAuthRoles.cs:15). No write-gate policy exists.
Exhaustive routable-page census (38 components, every @page in the AdminUI assembly)
| # | Page (file, attribute line) | Route(s) | Current attribute | Mutates? |
|---|---|---|---|---|
| 1 | Uns/GlobalUns.razor:2 |
/uns |
bare [Authorize] |
Yes — Area/Line/Equipment CRUD, CSV import |
| 2 | Uns/EquipmentPage.razor:7 |
/uns/equipment/new, /uns/equipment/{EquipmentId} |
bare [Authorize] |
Yes — equipment + tag/vtag/alarm CRUD |
| 3 | Clusters/NewCluster.razor:2 |
/clusters/new |
bare [Authorize] |
Yes |
| 4 | Clusters/ClusterEdit.razor:4 |
/clusters/{ClusterId}/edit |
bare [Authorize] |
Yes |
| 5 | Clusters/NodeEdit.razor:5 |
/clusters/{ClusterId}/nodes/new, …/nodes/{NodeId} |
bare [Authorize] |
Yes |
| 6 | Clusters/NamespaceEdit.razor:6 |
/clusters/{ClusterId}/namespaces/new, …/{NamespaceId} |
bare [Authorize] |
Yes |
| 7 | Clusters/AclEdit.razor:3 |
/clusters/{ClusterId}/acls/new, …/{NodeAclId} |
bare [Authorize] |
Yes — ACL grants |
| 8 | Clusters/Drivers/DriverTypePicker.razor:4 |
/clusters/{ClusterId}/drivers/new |
bare [Authorize] |
Gateway to create flow |
| 9 | Clusters/Drivers/DriverEditRouter.razor:5 |
/clusters/{ClusterId}/drivers/{DriverInstanceId} |
bare [Authorize] |
Dispatches to typed editors |
| 10–17 | Clusters/Drivers/{Modbus,S7,AbCip,AbLegacy,TwinCAT,Focas,OpcUaClient,Galaxy}DriverPage.razor:2 (each) |
/clusters/{ClusterId}/drivers/new/<slug> |
bare [Authorize] ×8 |
Yes — driver config incl. live ResilienceConfig (DriverResilienceSection embedded in all 8) |
| 18 | Deployments.razor:12 |
/deployments |
[Authorize(Roles = "Administrator,Designer")] (short form; in-file @using) |
Yes — deploy trigger |
| 19 | Scripts.razor:2 |
/scripts |
[…Authorize(Roles = "Administrator,Designer")] |
Yes — script list + delete |
| 20 | ScriptEdit.razor:5 |
/scripts/new, /scripts/{ScriptId} |
[…Authorize(Roles = "Administrator,Designer")] |
Yes — script CRUD |
| 21 | RoleGrants.razor:2 |
/role-grants |
[…Authorize(Policy = "FleetAdmin")] |
Yes — Admin-only, keep |
| 22 | Home.razor |
/ |
⚠ NO attribute at all (protected only by the endpoint FallbackPolicy; AuthorizeRouteView sees no requirement) |
No |
| 23 | Fleet.razor:6 |
/fleet |
bare [Authorize] |
No |
| 24 | Hosts.razor:7 |
/hosts |
bare [Authorize] |
No (driver ops gated imperatively in DriverStatusPanel) |
| 25 | Alerts.razor:5 |
/alerts |
bare [Authorize] |
View only (Ack/Shelve imperatively DriverOperator-gated at :165) |
| 26 | ScriptLog.razor:4 |
/script-log |
bare [Authorize] |
No |
| 27 | AlarmsHistorian.razor:4 |
/alarms-historian |
bare [Authorize] |
No |
| 28 | Account.razor:6 |
/account |
bare [Authorize] |
No |
| 29 | Certificates.razor:2 |
/certificates |
bare [Authorize] page-level; mutating actions already FleetAdmin (AuthorizeView :90, imperative :186) |
View at page level |
| 30 | Reservations.razor:2 |
/reservations |
bare [Authorize] |
⚠ No — CORRECTION: the page is a read-only AsNoTracking list (Reservations.razor:63-69); no mutation handler exists. The report's C-1 table lumps it into the mutating set — reclassified read-only here. |
| 31 | Clusters/ClustersList.razor:2 |
/clusters |
bare [Authorize] |
No — nav links only |
| 32 | Clusters/ClusterOverview.razor:2 |
/clusters/{ClusterId} |
bare [Authorize] |
No — links to gated editors |
| 33 | Clusters/ClusterAudit.razor:2 |
/clusters/{ClusterId}/audit |
bare [Authorize] |
No |
| 34 | Clusters/ClusterAcls.razor:2 |
/clusters/{ClusterId}/acls |
bare [Authorize] |
No — New/Edit are href links to AclEdit (verified: no in-place mutation) |
| 35 | Clusters/ClusterNamespaces.razor:2 |
/clusters/{ClusterId}/namespaces |
bare [Authorize] |
No — links to NamespaceEdit |
| 36 | Clusters/ClusterDrivers.razor:2 |
/clusters/{ClusterId}/drivers |
bare [Authorize] |
No — links to driver pages |
| 37 | Clusters/ClusterRedundancy.razor:2 |
/clusters/{ClusterId}/redundancy |
bare [Authorize] |
⚠ No — CORRECTION: pure read-only view of static redundancy config (ClusterRedundancy.razor:94-107, AsNoTracking, no form). Round-1 plan had slated it for ConfigEditor; reclassified read-only. |
| 38 | Login.razor:12 |
/login |
[…AllowAnonymous] |
— (must stay anonymous; Admin-001) |
Non-page authorization surfaces (idiom census)
| Site | Current | Disposition |
|---|---|---|
ScriptAnalysis/ScriptAnalysisEndpoints.cs:18 |
RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator,Designer" }) |
converge → ConfigEditor policy |
Alerts.razor:165, Shared/Drivers/DriverStatusPanel.razor:166, Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor:124, Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor:75 |
imperative AuthorizeAsync(…, "DriverOperator") string literal ×4 |
literal → constant (behavior unchanged) |
Certificates.razor:90 (<AuthorizeView Policy="FleetAdmin">), :186 (imperative "FleetAdmin") |
string literals | literal → constant (behavior unchanged) |
Security/ServiceCollectionExtensions.cs:155,160 |
AddPolicy("DriverOperator"…), AddPolicy("FleetAdmin"…) literals |
literal → constant; add ConfigEditor + AuthenticatedRead |
Hubs (AlertHub/DriverStatusHub/FleetStatusHub/ScriptLogHub) [Authorize] |
read-only feeds | unchanged (out of scope) |
Api/DeployApiEndpoints.cs |
AllowAnonymous + fixed-time X-Api-Key |
unchanged (correct, test-pinned) |
Round-1 plan cross-check: the ConfigEditor policy + reflection-guard proposal in 04-adminui-plan.md §1 is
sound and retained. Refreshed here: (a) Reservations + ClusterRedundancy move to the read tier (code-verified
above); (b) the four "mixed list pages" need no AuthorizeView wrapping — they contain only href links to
pages that are themselves gated (round 1's option A is unnecessary; a Viewer clicking "New driver" lands on the
NotAuthorized slot from Routes.razor:11-22 — acceptable, and hiding the links is deferred UX polish);
(c) extended to a single-idiom end state: every routable page carries an explicit named-policy attribute, so
the guard can enforce "no bare [Authorize] anywhere" instead of maintaining an allowed-bare list.
R2-05 (High, 04/C-1) — One authz idiom; gate the mutating surface
Restatement
Three authorization idioms coexist (bare attribute / role-string literals / named policy), and the largest
mutating surface — /uns, equipment, every cluster/node/namespace/ACL editor, all 8 driver pages (which since
#10/#13 author production-live retry/breaker ResilienceConfig) — carries only bare [Authorize], i.e. the
RequireAuthenticatedUser fallback. Any authenticated user, including a read-only Viewer, can create, edit,
and delete UNS structure, tags, scripts-adjacent config, driver instances, ACL grants, and live resilience
overrides. Only Deployments/Scripts/ScriptEdit (role strings) and RoleGrants (policy) are gated.
Verification
Confirmed at f6eaa267 — see the census above. Nothing is stale; two classification corrections (Reservations,
ClusterRedundancy read-only) and one addition (Home has no attribute) were found and are folded into the design.
Root cause
Pages were authored by copying the nearest neighbor, and the fused Host's FallbackPolicy makes bare
[Authorize] look protective (login is required) while providing zero role separation. No policy-name constants
exist, so the correct gate is undiscoverable and drift is the path of least resistance; nothing in CI observes
which attribute a page carries (no bUnit; razor @code/attributes are live-only), so the gap survived every
review until an attribute-metadata census was run.
Proposed design
Standardize on ONE idiom — named policies referenced via constants — and make the guard enforce it.
Alternatives considered
| Option | Description | Verdict |
|---|---|---|
(a) Per-page Roles="…" strings |
Stamp Roles = "Administrator,Designer" on the mutating pages (extend the Deployments idiom) |
Rejected. Role strings are brittle — the ConfigEditor→Designer / FleetAdmin→Administrator canonical rename already proved the vocabulary moves; every page would encode it. Leaves three idioms in place (RoleGrants keeps policy, imperative sites keep policy names). No compile-time anchor, so the guard would string-match. |
| (b) Named policies + constants (recommended) | One AdminUiPolicies constants type; a policy per capability tier; every page declares [Authorize(Policy = AdminUiPolicies.X)]; imperative/AuthorizeView/endpoint sites use the same constants |
Chosen. Collapses all three idioms into one; the role vocabulary lives in exactly one AddPolicy block; constants make gates discoverable and give the reflection guard a compile-time-safe expected map. Matches maintainer posture: global roles, simplest thing that works. |
| (c) Authorize-by-folder convention | Guard infers the required policy from the page's folder (Pages/Uns/**, Pages/Clusters/Drivers/** → ConfigEditor; Pages/** else → read) with no per-page attribute changes beyond the mutating set |
Rejected. Blazor has no folder-level attribute mechanism, so this is a pure test-side convention: implicit, and a file move silently changes a security gate. Also can't express the real split (read-only ClusterRedundancy lives beside editors in Clusters/). Explicit per-page attributes + an exhaustive map are the same cost and self-documenting. |
(d) Tighten the FallbackPolicy to ConfigEditor |
One-line change | Rejected. Locks Viewers out of every dashboard; wrong tier for read pages; hides the per-page intent. |
Policy set and role mapping
New file src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs (beside DevAuthRoles.cs, namespace
ZB.MOM.WW.OtOpcUa.Security.Auth) — string values of the two existing policies are kept identical to today's
literals so nothing must change atomically:
/// <summary>
/// Names of the AdminUI authorization policies registered by <c>AddOtOpcUaAuth</c>.
/// Single source of truth — pages, AuthorizeView blocks, imperative checks, and endpoint
/// groups must reference these constants, never string literals (guarded by
/// PageAuthorizationGuardTests). Policy → role mapping lives in ServiceCollectionExtensions.
/// </summary>
public static class AdminUiPolicies
{
/// <summary>Fleet-wide admin actions (RoleGrants page, certificate mutations). Roles: Administrator.</summary>
public const string FleetAdmin = "FleetAdmin";
/// <summary>Live driver operations (reconnect/restart, alarm ack/shelve, live address browse). Roles: Operator, Administrator.</summary>
public const string DriverOperator = "DriverOperator";
/// <summary>Config authoring: UNS/equipment/cluster/driver/ACL/namespace/script editors + deploy + script analysis. Roles: Administrator, Designer.</summary>
public const string ConfigEditor = "ConfigEditor";
/// <summary>Read-only AdminUI access (dashboards, lists, live tails). Any authenticated user.</summary>
public const string AuthenticatedRead = "AuthenticatedRead";
}
Registration in AddOtOpcUaAuth (ServiceCollectionExtensions.cs:143-161) — switch the two existing AddPolicy
names to the constants and add:
// ConfigEditor: may author configuration (UNS, equipment, clusters, drivers incl. live
// ResilienceConfig, ACLs, namespaces, scripts) and trigger deploys. Same semantics the
// Deployments/Scripts/ScriptEdit pages previously spelled as Roles="Administrator,Designer".
o.AddPolicy(AdminUiPolicies.ConfigEditor, policy =>
policy.RequireRole("Administrator", "Designer"));
// AuthenticatedRead: explicit name for "any authenticated user" so read-only pages can carry
// a non-bare attribute and the page-authorization guard can ban bare [Authorize] outright.
// Semantically identical to the FallbackPolicy.
o.AddPolicy(AdminUiPolicies.AuthenticatedRead, policy =>
policy.RequireAuthenticatedUser());
| Policy | Satisfied by roles | Gates |
|---|---|---|
FleetAdmin |
Administrator |
RoleGrants page; Certificates mutating actions (AuthorizeView + imperative — already so) |
ConfigEditor (new) |
Administrator, Designer |
the 20 config pages (below) + /api/script-analysis/* |
DriverOperator |
Operator, Administrator |
imperative sites only (Alerts ack/shelve, DriverStatusPanel reconnect/restart, both live address pickers) — unchanged |
AuthenticatedRead (new) |
any authenticated (Viewer, Designer, Administrator, Operator, role-less) |
the 16 read-only pages |
(none — [AllowAnonymous]) |
— | Login only |
The dev rig is unaffected: AutoLoginAuthenticationHandler grants DevAuthRoles.All = every AdminRole +
Operator (DevAuthRoles.cs:18), which satisfies all four policies.
Page classification (target state — the guard's expected map)
- ConfigEditor (20):
GlobalUns,EquipmentPage,NewCluster,ClusterEdit,NodeEdit,NamespaceEdit,AclEdit,DriverTypePicker,DriverEditRouter,ModbusDriverPage,S7DriverPage,AbCipDriverPage,AbLegacyDriverPage,TwinCATDriverPage,FocasDriverPage,OpcUaClientDriverPage,GalaxyDriverPage,Deployments,Scripts,ScriptEdit. - FleetAdmin (1):
RoleGrants. - AuthenticatedRead (16):
Home,Fleet,Hosts,Alerts,ScriptLog,AlarmsHistorian,Account,Certificates,Reservations,ClustersList,ClusterOverview,ClusterAudit,ClusterAcls,ClusterNamespaces,ClusterDrivers,ClusterRedundancy. - AllowAnonymous (1):
Login(must stay — Admin-001).
The DriverTypePicker/DriverEditRouter gateways and the 8 typed pages are all gated so a Viewer can't reach a
driver form by any route. The four list pages stay viewable (their New/Edit href targets are gated; hiding the
links behind <AuthorizeView Policy="@AdminUiPolicies.ConfigEditor"> is deferred UX polish, not security).
Implementation steps
- Constants: add
src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs(code above). - Registration: in
src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.csswitchAddPolicy("DriverOperator"…)/AddPolicy("FleetAdmin"…)(:155/:160) to the constants; add theConfigEditorandAuthenticatedReadregistrations. - Imports: append to
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor:Then remove the now-duplicate in-file@using Microsoft.AspNetCore.Authorization @using ZB.MOM.WW.OtOpcUa.Security.Auth@using Microsoft.AspNetCore.AuthorizationfromDeployments.razor:2andAlerts.razor:7(avoids CS0105 underTreatWarningsAsErrors). - Page attribute edits — replace the existing
@attributeline (cited in the census) in every file with the exact line below; forHome.razorinsert as line 2 (after@page "/"):File (under src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/)Line New attribute line Uns/GlobalUns.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Uns/EquipmentPage.razor7 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/NewCluster.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/ClusterEdit.razor4 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/NodeEdit.razor5 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/NamespaceEdit.razor6 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/AclEdit.razor3 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/DriverTypePicker.razor4 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/DriverEditRouter.razor5 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/ModbusDriverPage.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/S7DriverPage.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/AbCipDriverPage.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/AbLegacyDriverPage.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/TwinCATDriverPage.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/FocasDriverPage.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/OpcUaClientDriverPage.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Clusters/Drivers/GalaxyDriverPage.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Deployments.razor12 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]Scripts.razor2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]ScriptEdit.razor5 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]RoleGrants.razor2 @attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]Home.razorinsert 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Fleet.razor6 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Hosts.razor7 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Alerts.razor5 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]ScriptLog.razor4 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]AlarmsHistorian.razor4 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Account.razor6 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Certificates.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Reservations.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Clusters/ClustersList.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Clusters/ClusterOverview.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Clusters/ClusterAudit.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Clusters/ClusterAcls.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Clusters/ClusterNamespaces.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Clusters/ClusterDrivers.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Clusters/ClusterRedundancy.razor2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]Login.razor12 unchanged ( […AllowAnonymous]) - Literal convergence (non-page sites):
ScriptAnalysis/ScriptAnalysisEndpoints.cs:18→.RequireAuthorization(AdminUiPolicies.ConfigEditor)(drop thenew AuthorizeAttribute { Roles = … }; addusing ZB.MOM.WW.OtOpcUa.Security.Auth;; update the method's comment + xmldoc to say "ConfigEditor policy (Administrator or Designer)"). Behavior identical.- Imperative
"DriverOperator"→AdminUiPolicies.DriverOperatorinAlerts.razor:165,Components/Shared/Drivers/DriverStatusPanel.razor:166,Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor:124,Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor:75. Certificates.razor:90→<AuthorizeView Policy="@AdminUiPolicies.FleetAdmin">;:186imperative literal →AdminUiPolicies.FleetAdmin.
- Docs: update
CLAUDE.md(ScriptAnalysis sentence: "gated to theAdministrator,Designerroles" → "gated by theConfigEditorpolicy (Administrator or Designer)") anddocs/security.mdif it states AdminUI page gating (grep forFleetAdmin/Authorizethere) — the C-2 doc-drift lesson: change docs in the same PR as the gate. - STATUS/report bookkeeping: add the R2-05 row to
archreview/plans/STATUS.mdand flip 04/C-1 inarchreview/04-adminui.md's prior-finding table when merged.
Tests
No bUnit exists (documented repo posture), and the docker-dev rig auto-authenticates with ALL roles
(DisableLogin=true → DevAuthRoles.All), so a Viewer-denied path can NEVER be observed on the default rig.
The negative proof is therefore CI-side: the reflection guard + the policy-semantics unit test. Live /run is
the positive/regression pass only.
- Reflection page-authorization guard (the no-bUnit substitute; the single highest-leverage test here) —
new
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs. Works purely off component metadata (razor@attribute/@pagecompile to class-level attributes), needs no rendering:- Enumerate
typeof(Routes).Assembly.GetTypes()where the type carries at least oneMicrosoft.AspNetCore.Components.RouteAttribute— the same assembly anchorRoutes.razorgives the<Router AppAssembly=…>, so the guard's universe is exactly the router's universe. - Rule 1 (no bare gates): every routable type must carry
[AllowAnonymous]or anAuthorizeAttributewith a non-emptyPolicyand a nullRoles— bans bare[Authorize], theRoles="…"idiom, and attribute-less pages (catches today'sHome). Failure message lists every offender with its route template. - Rule 2 (exhaustive classification): a compile-time
Dictionary<Type, string>expected map (the 38-page table above,typeof(GlobalUns)→AdminUiPolicies.ConfigEditor, …) plus an[AllowAnonymous]allowlist ={ typeof(Login) }. Assert (a) every routable type appears in exactly one of the two sets and (b) its actual attribute matches. A brand-new page fails the test until the author consciously classifies it — the anti-drift property, and this repo's "assert production wiring" house pattern (cf. the deferred-sink forwarding guard). - Rule 3 (known policy names): every
Policyvalue must be one of the fourAdminUiPoliciesconstants. - TDD: write this test FIRST against the target map — it must FAIL at
f6eaa267listing the ~35 offending pages (34 bare +Homeattribute-less; onlyRoleGrantspasses Rule 1 as-is, andDeployments/Scripts/ScriptEditfail on the Roles idiom). Green only after every page edit lands. - Note: if
AuthorizeAttribute/RouteAttributedon't resolve in the test project, add<FrameworkReference Include="Microsoft.AspNetCore.App"/>totests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj(the AdminUI RCL reference normally flows it transitively).
- Enumerate
- Policy-registration/semantics test — new
tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs, mirroring the existingAddOtOpcUaAuthWiringTestsconstruction (in-memory config withSecurity:Auth:DisableLogin=false,services.AddLogging(); services.AddOtOpcUaAuth(config);, build provider). ResolveIAuthorizationServiceand evaluate each policy againstClaimsPrincipals built from role claims (new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), authenticationType: "Test"); an identity with no authenticationType = unauthenticated):ConfigEditor:Viewerdenied;Operatordenied;Designerallowed;Administratorallowed; unauthenticated denied.AuthenticatedRead: any authenticated (incl. role-less) allowed; unauthenticated denied.FleetAdmin: onlyAdministrator.DriverOperator:Operator+Administratoronly. (Pins the two pre-existing policies for the first time.)- Dev-rig protection:
DevAuthRoles.Allsatisfies all four policies (guards the auto-login principal against a future policy requiring a role outside the grant).
- Existing suites must stay green — notably the 8
*FormSerializationTests(driver pages),Unsservice tests, andScriptAnalysissuites (service-level; the endpoint gate change is metadata-only). - Live
/runverification pass (docker-dev, positive/regression only — REQUIRED): rebuild both central-1 and central-2 images (:9200round-robins), bring the localdocker-devstack up, then with the auto-admin session drive:/→/uns(create+delete a scratch area) →/uns/equipment/{id}(open Tags tab) →/clusters→ a driver page (open the resilience section) →/deployments(trigger a deploy) →/role-grants→/alerts. Everything must load and mutate exactly as before — auto-login carries all roles, so any page that breaks under the new attributes is a real over-gating regression (e.g. a typo'd policy name renders the permission-denied slot even for the admin — this is precisely what the pass catches, and why it cannot be skipped). Do not treat this pass as negative-authz evidence — disabled login auto-authenticates as a full-access admin; the deny paths are proven by tests 1+2. Optional stretch (real deny observation): setSecurity__Auth__DisableLogin=falseon one AdminUI container against the shared GLAuth (10.100.0.35:3893), bind a Viewer-mapped user, and confirm/unsrenders theNotAuthorizedslot while/clustersstill lists.
Effort + Risk
Effort: Small-Medium (~2.5–4 h): 2 new small files, 1 policy-registration edit, 37 one-line page edits, 7 literal conversions, 2 test files, docs touch, live pass. Risk: Medium — wide but shallow blast radius (every AdminUI page's gate changes); each change is one declarative line; no data-plane impact (OPC UA auth is a separate LDAP path); the guard + live pass mitigate the two real failure modes (over-gating a read page / under-gating a mutation page).
Locked-out-user analysis (what each existing role sees before → after):
| Role | Before (f6eaa267) |
After R2-05 | Change |
|---|---|---|---|
Viewer |
every page including all config mutation except Deployments/Scripts/ScriptEdit/RoleGrants | the 16 read pages only; denied on all 20 ConfigEditor pages + RoleGrants | Intended fix — this is the finding. Viewer keeps all dashboards/lists/live tails. |
Designer |
everything except RoleGrants + Certificates mutating actions | identical | No change (ConfigEditor = Administrator+Designer, same set the role-string pages used). |
Operator (appsettings-only) |
every page including config mutation (bare-[Authorize] hole) + DriverOperator actions |
read pages + DriverOperator imperative actions; newly denied on config pages | Deliberate narrowing — Operator is the live-ops role, not a config author. ⚠ Deployment check: if any real LDAP deployment maps a config-authoring group to Operator via GroupToRole/RoleGrants, remap it to Designer before rollout. |
Administrator |
everything | everything | No change. |
| Auto-login dev principal | all roles | all roles (DevAuthRoles.All ⊇ Administrator+Designer+Operator) |
No dev-rig regression (pinned by test 2). |
| Authenticated, no mapped role | every page incl. mutation (!) | read pages only | Also narrowed, correctly. |
Task breakdown
Branch: fix/archreview-r2-05-adminui-authz off master f6eaa267. Build gate per commit:
dotnet build ZB.MOM.WW.OtOpcUa.slnx green. Conventional commits as noted. Tasks 6–11 (page edits) are pure
one-line-per-file mechanical edits and are highly parallelizable (disjoint files) — if parallel implementers
are used, worktree-isolate (shared-tree git-index race, see memory).
Task 1 — Add AdminUiPolicies constants
Classification: small
Estimated implement time: ~3 min
Parallelizable with: none (everything depends on it)
Files: create src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs
Add the four-constant static class exactly as in the design (values "FleetAdmin", "DriverOperator",
"ConfigEditor", "AuthenticatedRead"). Build the Security project. No commit yet (commits with Task 4).
Task 2 — Policy-semantics unit test (RED)
Classification: small (TDD red)
Estimated implement time: ~5 min
Parallelizable with: Task 3
Files: create tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs
Write the IAuthorizationService matrix test per the design (mirror AddOtOpcUaAuthWiringTests provider
construction). Include the four-policy matrix + the DevAuthRoles.All assertion. Run:
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter "FullyQualifiedName~AdminUiPoliciesTests"
Must FAIL: ConfigEditor/AuthenticatedRead are not registered (InvalidOperationException: No policy found).
The FleetAdmin/DriverOperator cases pass already — keep them in the same class as pins.
Task 3 — Reflection page-authorization guard (RED — must list the ungated pages)
Classification: small (TDD red; the load-bearing guard)
Estimated implement time: ~5 min
Parallelizable with: Task 2
Files: create tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs
(+ possibly <FrameworkReference Include="Microsoft.AspNetCore.App"/> in the test csproj)
Implement Rules 1–3 over typeof(Routes).Assembly with the full 38-type expected map from the design (compile-time
typeof(...) references). Run:
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~PageAuthorizationGuardTests"
Must FAIL listing every offender by route: 31 bare-[Authorize] pages, Home (no attribute), and
Deployments/Scripts/ScriptEdit (Roles idiom) — ~35 total; only RoleGrants + Login classify clean.
Commit nothing yet.
Task 4 — Register ConfigEditor + AuthenticatedRead; constants for existing policies (GREEN for Task 2)
Classification: small
Estimated implement time: ~4 min
Parallelizable with: Task 5 (after Task 1)
Files: src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs (:143-161)
Add the two AddPolicy blocks from the design; switch "DriverOperator"/"FleetAdmin" literals to the
constants. Re-run the Task 2 filter — all green.
Commit: feat(security): add ConfigEditor + AuthenticatedRead AdminUI policies behind AdminUiPolicies constants (Tasks 1+2+4; include the still-red guard? No — the guard lands with Task 11's final green).
Task 5 — _Imports.razor usings + de-dupe
Classification: small
Estimated implement time: ~2 min
Parallelizable with: Task 4
Files: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor; Components/Pages/Deployments.razor (:2);
Components/Pages/Alerts.razor (:7)
Append @using Microsoft.AspNetCore.Authorization + @using ZB.MOM.WW.OtOpcUa.Security.Auth to _Imports.razor;
delete the now-duplicate in-file @using Microsoft.AspNetCore.Authorization lines from Deployments/Alerts. Build
AdminUI.
Task 6 — Gate UNS pages (ConfigEditor)
Classification: small (mechanical)
Estimated implement time: ~2 min
Parallelizable with: Tasks 7, 8, 9, 10
Files: Components/Pages/Uns/GlobalUns.razor (:2), Components/Pages/Uns/EquipmentPage.razor (:7)
Replace each @attribute line with @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)].
Task 7 — Gate cluster editors (ConfigEditor)
Classification: small (mechanical)
Estimated implement time: ~4 min
Parallelizable with: Tasks 6, 8, 9, 10
Files: Components/Pages/Clusters/NewCluster.razor (:2), ClusterEdit.razor (:4), NodeEdit.razor (:5),
NamespaceEdit.razor (:6), AclEdit.razor (:3)
Same replacement, ConfigEditor.
Task 8 — Gate driver pages + routers (ConfigEditor)
Classification: small (mechanical)
Estimated implement time: ~5 min
Parallelizable with: Tasks 6, 7, 9, 10
Files: Components/Pages/Clusters/Drivers/: DriverTypePicker.razor (:4), DriverEditRouter.razor (:5),
ModbusDriverPage.razor, S7DriverPage.razor, AbCipDriverPage.razor, AbLegacyDriverPage.razor,
TwinCATDriverPage.razor, FocasDriverPage.razor, OpcUaClientDriverPage.razor, GalaxyDriverPage.razor (all :2)
Same replacement, ConfigEditor — this is the surface that authors live ResilienceConfig.
Task 9 — Converge role-string pages onto ConfigEditor
Classification: small (mechanical)
Estimated implement time: ~2 min
Parallelizable with: Tasks 6, 7, 8, 10
Files: Components/Pages/Deployments.razor (:12 — note line shifts if Task 5 removed :2), Scripts.razor (:2),
ScriptEdit.razor (:5)
Replace the Roles = "Administrator,Designer" attributes with @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)].
Semantics identical (same two roles).
Task 10 — Read pages to AuthenticatedRead; RoleGrants to constant; Home gains its attribute
Classification: small (mechanical)
Estimated implement time: ~5 min
Parallelizable with: Tasks 6, 7, 8, 9
Files: Components/Pages/: Home.razor (insert :2), Fleet.razor (:6), Hosts.razor (:7), Alerts.razor
(:5), ScriptLog.razor (:4), AlarmsHistorian.razor (:4), Account.razor (:6), Certificates.razor (:2),
Reservations.razor (:2), RoleGrants.razor (:2), Clusters/ClustersList.razor, ClusterOverview.razor,
ClusterAudit.razor, ClusterAcls.razor, ClusterNamespaces.razor, ClusterDrivers.razor,
ClusterRedundancy.razor (all :2)
Read pages → @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)];
RoleGrants.razor → @attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)].
Task 11 — Guard goes GREEN; commit the gate
Classification: small (TDD green checkpoint) Estimated implement time: ~3 min Parallelizable with: none (join point for 5–10) Files: none (verification)
dotnet build ZB.MOM.WW.OtOpcUa.slnx
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~PageAuthorizationGuardTests"
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests
Guard must be fully green (all 38 pages classified + matching); full AdminUI.Tests green (443 existing + new).
Commit: fix(adminui): gate every page with named policies — ConfigEditor on the mutating surface (R2-05, 04/C-1)
(Tasks 3+5–11).
Task 12 — Converge non-page literals onto the constants
Classification: small
Estimated implement time: ~5 min
Parallelizable with: Task 13
Files: ScriptAnalysis/ScriptAnalysisEndpoints.cs (:18 + comment/xmldoc), Components/Pages/Alerts.razor
(:165), Components/Shared/Drivers/DriverStatusPanel.razor (:166),
Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor (:124),
Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor (:75),
Components/Pages/Certificates.razor (:90, :186)
Per implementation step 5. Then:
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ScriptAnalysis"
Commit: refactor(adminui): replace authorization string literals with AdminUiPolicies constants.
Task 13 — Docs: CLAUDE.md + security guide
Classification: small
Estimated implement time: ~4 min
Parallelizable with: Task 12
Files: CLAUDE.md (ScriptAnalysis gating sentence), docs/security.md (grep FleetAdmin/Administrator,Designer/
AdminUI gating statements and align), archreview/plans/STATUS.md (add the R2-05 row)
Doc-drift discipline (the C-2 lesson): the gate and its documentation change in the same PR.
Commit: docs(security): document AdminUI policy tiers (ConfigEditor/AuthenticatedRead) + R2-05 status.
Task 14 — Full-solution test sweep
Classification: small (verification) Estimated implement time: ~5 min (wall time longer; single command) Parallelizable with: none Files: none
dotnet test ZB.MOM.WW.OtOpcUa.slnx
All unit suites green (integration suites needing fixtures skip cleanly per repo posture). Fix any straggler (most likely: a page file whose attribute line number shifted — the guard's failure message names it).
Task 15 — Live /run verification on docker-dev (positive/regression pass)
Classification: live-verify (required; cannot be skipped — no bUnit) Estimated implement time: ~20 min Parallelizable with: none Files: none
Rebuild both central-1 and central-2 images (:9200 round-robins between them), docker-dev up, then with
the auto-admin session (login disabled ⇒ auto-authenticated full-access admin — positive testing only; the
negative/deny proof is Tasks 2+3, not this rig): drive / → /uns (create + delete a scratch area) →
/uns/equipment/{id} → /clusters → /clusters/{id}/drivers → a driver page (open resilience section) →
/deployments (deploy) → /role-grants → /alerts → /certificates. Every page must render and mutate as
before; any permission-denied slot seen by the auto-admin is an over-gating bug (typo'd policy name). Optional
stretch: one container with Security__Auth__DisableLogin=false against shared GLAuth 10.100.0.35:3893, bind a
Viewer user, observe the /uns deny + /clusters allow.
Task 16 — Merge + bookkeeping
Classification: small
Estimated implement time: ~4 min
Parallelizable with: none
Files: archreview/04-adminui.md (C-1 row → FIXED w/ commit), archreview/plans/STATUS.md
Merge fix/archreview-r2-05-adminui-authz to master (user's call on push), flip the 04/C-1 prior-finding row,
record the two verification corrections (Reservations/ClusterRedundancy read-only) in STATUS.md.
Total: 16 tasks · overall effort Small-Medium (~2.5–4 h including the live pass).
Execution deviations (R2-05)
Executed 2026-07-13 on branch r2/05-adminui-authz (off master 1676c8f4, not f6eaa267 — the plan's
stated base; the two commits between are docs-only merges with zero AdminUI source change, so the census
re-verified identical). Commits: 183b72b7 (T1/2/4) · b5bf4b73 (T3/5–11, the gate) · aac32854 (T12
literals) · dad78351 (T13 docs) · plus this bookkeeping commit (T16).
- DriverOperator role string uses
DevAuthRoles.Operatorconstant, not the"Operator"literal. InServiceCollectionExtensions.cstheRequireRole("Operator", …)now readsRequireRole(DevAuthRoles.Operator, …). Reversible, zero behavior change (same string) — small consistency win beyond the plan's letter, which only called for switching the policy name to a constant."Administrator"/"Designer"role strings kept literal (no constant exists for them; out of scope). - Guard test: two page types fully-qualified.
typeof(Hosts)andtypeof(Certificates)collide with sibling namespaces (…Pages.Hosts, a folder namespace exists), so those two entries usetypeof(global::…Components.Pages.Hosts)/…Certificates. The other 36 resolve via@using. No<FrameworkReference>was needed —RouteAttribute/AuthorizeAttribute/AllowAnonymousAttributeall resolved transitively through the AdminUI RCL reference (the plan flagged this as a possible add). MapScriptAnalysis→MapScriptAnalysisEndpoints. The plan (and the old CLAUDE.md sentence) named the methodMapScriptAnalysis; the actual method isMapScriptAnalysisEndpoints. Corrected the doc references to the real name while converging its literal toAdminUiPolicies.ConfigEditor.- T14 full-solution sweep run once, then constrained off. A single
dotnet test ZB.MOM.WW.OtOpcUa.slnxran before the controller's mid-task constraint (no whole-solution / no*.IntegrationTests— heavy suites leak ~16 GB). All failures were triaged as pre-existing/environmental and outside the R2-05 diff: Core.Abstractions.TestsInterfaceIndependenceflags pre-existingHistorian.*namespace types; a Runtime historian-retry timing test; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures. The authoritative gate is the impacted unit suites — AdminUI.Tests 517/517 and Security.Tests 80/80, both green. The sweep will not be re-run per the constraint. - T15 live
/rundeferred-live. Markeddeferred-live("docker-dev live authz /run; serial live pass"). Two reasons: (1) it is a heavy integration/browser-drive pass the controller runs serialized; (2) docker-devDisableLogin=trueauto-authenticates as a full-access admin, so it can only regression-check over-gating, never observe a role deny — the negative/deny proof is entirely CI-side (T2 + T3, green). - T16 does not merge. Executor scope forbids merge/push/PR; the branch is left ready. Bookkeeping (04/C-1 row flipped to FIXED, the two read-only classification corrections recorded, STATUS.md R2-05 row) is done.