# 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`](../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`](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 | | 10–17 | `Clusters/Drivers/{Modbus,S7,AbCip,AbLegacy,TwinCAT,Focas,OpcUaClient,Galaxy}DriverPage.razor:2` (each) | `/clusters/{ClusterId}/drivers/new/` | 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` (``), `: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: ```csharp /// /// Names of the AdminUI authorization policies registered by AddOtOpcUaAuth. /// 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. /// public static class AdminUiPolicies { /// Fleet-wide admin actions (RoleGrants page, certificate mutations). Roles: Administrator. public const string FleetAdmin = "FleetAdmin"; /// Live driver operations (reconnect/restart, alarm ack/shelve, live address browse). Roles: Operator, Administrator. public const string DriverOperator = "DriverOperator"; /// Config authoring: UNS/equipment/cluster/driver/ACL/namespace/script editors + deploy + script analysis. Roles: Administrator, Designer. public const string ConfigEditor = "ConfigEditor"; /// Read-only AdminUI access (dashboards, lists, live tails). Any authenticated user. public const string AuthenticatedRead = "AuthenticatedRead"; } ``` Registration in `AddOtOpcUaAuth` (`ServiceCollectionExtensions.cs:143-161`) — switch the two existing `AddPolicy` names to the constants and add: ```csharp // 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 `` 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`: ```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` → ``; `: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=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. 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.RouteAttribute` — **the same assembly anchor `Routes.razor` gives the ``**, 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` 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 `` 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 `ClaimsPrincipal`s 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.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: ```bash 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 `` 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: ```bash 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) ```bash 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: ```bash 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 ```bash 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.Operator` constant, not the `"Operator"` literal.** In `ServiceCollectionExtensions.cs` the `RequireRole("Operator", …)` now reads `RequireRole(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)` and `typeof(Certificates)` collide with sibling namespaces (`…Pages.Hosts`, a folder namespace exists), so those two entries use `typeof(global::…Components.Pages.Hosts)` / `…Certificates`. The other 36 resolve via `@using`. No `` was needed — `RouteAttribute`/`AuthorizeAttribute`/`AllowAnonymousAttribute` all 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 method `MapScriptAnalysis`; the actual method is `MapScriptAnalysisEndpoints`. Corrected the doc references to the real name while converging its literal to `AdminUiPolicies.ConfigEditor`. - **T14 full-solution sweep run once, then constrained off.** A single `dotnet test ZB.MOM.WW.OtOpcUa.slnx` ran 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.Tests `InterfaceIndependence` flags pre-existing `Historian.*` 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 `/run` deferred-live.** Marked `deferred-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-dev `DisableLogin=true` auto-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.