Files
lmxopcua/archreview/plans/R2-05-adminui-authz-plan.md
T
Joseph Doherty 1891f5d6a7 docs(archreview): 2026-07-12 re-review at f6eaa267 + 12 round-2 remediation plans
Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.

Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
2026-07-12 23:52:23 -04:00

40 KiB
Raw Blame History

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-1 04-adminui-plan.md — that section was re-verified at f6eaa267 and its ConfigEditor-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/breaker ResilienceConfig since #10 bacea1a4 / #13 75403caa merged)
  • Verified against tree at: master f6eaa267 (clean; zero AdminUI source changes since the 9cad9ed0 round-1 baseline)
  • Scope: page-level @attribute [Authorize…] across src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/, policy definitions in src/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-gated X-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
1017 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

  1. Constants: add src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs (code above).
  2. Registration: in src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs switch AddPolicy("DriverOperator"…)/AddPolicy("FleetAdmin"…) (:155/:160) to the constants; add the ConfigEditor and AuthenticatedRead registrations.
  3. Imports: append to src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor:
    @using Microsoft.AspNetCore.Authorization
    @using ZB.MOM.WW.OtOpcUa.Security.Auth
    
    Then remove the now-duplicate in-file @using Microsoft.AspNetCore.Authorization from Deployments.razor:2 and Alerts.razor:7 (avoids CS0105 under TreatWarningsAsErrors).
  4. Page attribute edits — replace the existing @attribute line (cited in the census) in every file with the exact line below; for Home.razor insert as line 2 (after @page "/"):
    File (under src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/) Line New attribute line
    Uns/GlobalUns.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Uns/EquipmentPage.razor 7 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/NewCluster.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/ClusterEdit.razor 4 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/NodeEdit.razor 5 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/NamespaceEdit.razor 6 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/AclEdit.razor 3 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/DriverTypePicker.razor 4 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/DriverEditRouter.razor 5 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/ModbusDriverPage.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/S7DriverPage.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/AbCipDriverPage.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/AbLegacyDriverPage.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/TwinCATDriverPage.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/FocasDriverPage.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/OpcUaClientDriverPage.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Clusters/Drivers/GalaxyDriverPage.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Deployments.razor 12 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    Scripts.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    ScriptEdit.razor 5 @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
    RoleGrants.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]
    Home.razor insert 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Fleet.razor 6 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Hosts.razor 7 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Alerts.razor 5 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    ScriptLog.razor 4 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    AlarmsHistorian.razor 4 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Account.razor 6 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Certificates.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Reservations.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Clusters/ClustersList.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Clusters/ClusterOverview.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Clusters/ClusterAudit.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Clusters/ClusterAcls.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Clusters/ClusterNamespaces.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Clusters/ClusterDrivers.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Clusters/ClusterRedundancy.razor 2 @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
    Login.razor 12 unchanged ([…AllowAnonymous])
  5. Literal convergence (non-page sites):
    • ScriptAnalysis/ScriptAnalysisEndpoints.cs:18.RequireAuthorization(AdminUiPolicies.ConfigEditor) (drop the new AuthorizeAttribute { Roles = … }; add using ZB.MOM.WW.OtOpcUa.Security.Auth;; update the method's comment + xmldoc to say "ConfigEditor policy (Administrator or Designer)"). Behavior identical.
    • Imperative "DriverOperator"AdminUiPolicies.DriverOperator in Alerts.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">; :186 imperative literal → AdminUiPolicies.FleetAdmin.
  6. Docs: update CLAUDE.md (ScriptAnalysis sentence: "gated to the Administrator,Designer roles" → "gated by the ConfigEditor policy (Administrator or Designer)") and docs/security.md if it states AdminUI page gating (grep for FleetAdmin/Authorize there) — the C-2 doc-drift lesson: change docs in the same PR as the gate.
  7. STATUS/report bookkeeping: add the R2-05 row to archreview/plans/STATUS.md and flip 04/C-1 in archreview/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=trueDevAuthRoles.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.

  1. 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/@page compile to class-level attributes), needs no rendering:
    • Enumerate typeof(Routes).Assembly.GetTypes() where the type carries at least one Microsoft.AspNetCore.Components.RouteAttributethe same assembly anchor Routes.razor gives 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 an AuthorizeAttribute with a non-empty Policy and a null Roles — bans bare [Authorize], the Roles="…" idiom, and attribute-less pages (catches today's Home). 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 Policy value must be one of the four AdminUiPolicies constants.
    • TDD: write this test FIRST against the target map — it must FAIL at f6eaa267 listing the ~35 offending pages (34 bare + Home attribute-less; only RoleGrants passes Rule 1 as-is, and Deployments/Scripts/ ScriptEdit fail on the Roles idiom). Green only after every page edit lands.
    • Note: if AuthorizeAttribute/RouteAttribute don't resolve in the test project, add <FrameworkReference Include="Microsoft.AspNetCore.App"/> to tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj (the AdminUI RCL reference normally flows it transitively).
  2. Policy-registration/semantics test — new tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs, mirroring the existing AddOtOpcUaAuthWiringTests construction (in-memory config with Security:Auth:DisableLogin=false, services.AddLogging(); services.AddOtOpcUaAuth(config);, build provider). Resolve IAuthorizationService and evaluate each policy against ClaimsPrincipals built from role claims (new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), authenticationType: "Test"); an identity with no authenticationType = unauthenticated):
    • ConfigEditor: Viewer denied; Operator denied; Designer allowed; Administrator allowed; unauthenticated denied.
    • AuthenticatedRead: any authenticated (incl. role-less) allowed; unauthenticated denied.
    • FleetAdmin: only Administrator. DriverOperator: Operator + Administrator only. (Pins the two pre-existing policies for the first time.)
    • Dev-rig protection: DevAuthRoles.All satisfies all four policies (guards the auto-login principal against a future policy requiring a role outside the grant).
  3. Existing suites must stay green — notably the 8 *FormSerializationTests (driver pages), Uns service tests, and ScriptAnalysis suites (service-level; the endpoint gate change is metadata-only).
  4. Live /run verification pass (docker-dev, positive/regression only — REQUIRED): rebuild both central-1 and central-2 images (:9200 round-robins), bring the local docker-dev stack 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): set Security__Auth__DisableLogin=false on one AdminUI container against the shared GLAuth (10.100.0.35:3893), bind a Viewer-mapped user, and confirm /uns renders the NotAuthorized slot while /clusters still lists.

Effort + Risk

Effort: Small-Medium (~2.54 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 611 (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 13 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 510) 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+511).

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.54 h including the live pass).