6c1f05cba9
v2-ci / build (pull_request) Successful in 3m50s
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (pull_request) Failing after 3m35s
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (pull_request) Failing after 1m42s
v2-ci / unit-tests (pull_request) Failing after 9m55s
612 lines
44 KiB
Markdown
612 lines
44 KiB
Markdown
# 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.
|