# 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/<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:

```csharp
/// <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:

```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 `<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`:
   ```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=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
     `<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 `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 `<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:

```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
  `<FrameworkReference>` 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.
