Files
lmxopcua/archreview/plans/R2-05-adminui-authz-plan.md
T
Joseph Doherty 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
docs(r2-05): flip 04/C-1 to FIXED, record execution deviations + task status
2026-07-13 10:07:27 -04:00

612 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| 1017 | `Clusters/Drivers/{Modbus,S7,AbCip,AbLegacy,TwinCAT,Focas,OpcUaClient,Galaxy}DriverPage.razor:2` (each) | `/clusters/{ClusterId}/drivers/new/<slug>` | bare `[Authorize]` ×8 | **Yes** — driver config **incl. live `ResilienceConfig`** (`DriverResilienceSection` embedded in all 8) |
| 18 | `Deployments.razor:12` | `/deployments` | `[Authorize(Roles = "Administrator,Designer")]` (short form; in-file `@using`) | **Yes** — deploy trigger |
| 19 | `Scripts.razor:2` | `/scripts` | `[…Authorize(Roles = "Administrator,Designer")]` | **Yes** — script list + delete |
| 20 | `ScriptEdit.razor:5` | `/scripts/new`, `/scripts/{ScriptId}` | `[…Authorize(Roles = "Administrator,Designer")]` | **Yes** — script CRUD |
| 21 | `RoleGrants.razor:2` | `/role-grants` | `[…Authorize(Policy = "FleetAdmin")]` | **Yes** — Admin-only, keep |
| 22 | `Home.razor` | `/` | ⚠ **NO attribute at all** (protected only by the endpoint FallbackPolicy; `AuthorizeRouteView` sees no requirement) | No |
| 23 | `Fleet.razor:6` | `/fleet` | bare `[Authorize]` | No |
| 24 | `Hosts.razor:7` | `/hosts` | bare `[Authorize]` | No (driver ops gated imperatively in `DriverStatusPanel`) |
| 25 | `Alerts.razor:5` | `/alerts` | bare `[Authorize]` | View only (Ack/Shelve imperatively `DriverOperator`-gated at :165) |
| 26 | `ScriptLog.razor:4` | `/script-log` | bare `[Authorize]` | No |
| 27 | `AlarmsHistorian.razor:4` | `/alarms-historian` | bare `[Authorize]` | No |
| 28 | `Account.razor:6` | `/account` | bare `[Authorize]` | No |
| 29 | `Certificates.razor:2` | `/certificates` | bare `[Authorize]` page-level; mutating actions already `FleetAdmin` (`AuthorizeView` :90, imperative :186) | View at page level |
| 30 | `Reservations.razor:2` | `/reservations` | bare `[Authorize]` | ⚠ **No — CORRECTION**: the page is a read-only `AsNoTracking` list (`Reservations.razor:63-69`); no mutation handler exists. The report's C-1 table lumps it into the mutating set — reclassified read-only here. |
| 31 | `Clusters/ClustersList.razor:2` | `/clusters` | bare `[Authorize]` | No — nav links only |
| 32 | `Clusters/ClusterOverview.razor:2` | `/clusters/{ClusterId}` | bare `[Authorize]` | No — links to gated editors |
| 33 | `Clusters/ClusterAudit.razor:2` | `/clusters/{ClusterId}/audit` | bare `[Authorize]` | No |
| 34 | `Clusters/ClusterAcls.razor:2` | `/clusters/{ClusterId}/acls` | bare `[Authorize]` | No — New/Edit are `href` links to `AclEdit` (verified: no in-place mutation) |
| 35 | `Clusters/ClusterNamespaces.razor:2` | `/clusters/{ClusterId}/namespaces` | bare `[Authorize]` | No — links to `NamespaceEdit` |
| 36 | `Clusters/ClusterDrivers.razor:2` | `/clusters/{ClusterId}/drivers` | bare `[Authorize]` | No — links to driver pages |
| 37 | `Clusters/ClusterRedundancy.razor:2` | `/clusters/{ClusterId}/redundancy` | bare `[Authorize]` | ⚠ **No — CORRECTION**: pure read-only view of static redundancy config (`ClusterRedundancy.razor:94-107`, `AsNoTracking`, no form). Round-1 plan had slated it for `ConfigEditor`; reclassified read-only. |
| 38 | `Login.razor:12` | `/login` | `[…AllowAnonymous]` | — (must stay anonymous; Admin-001) |
### Non-page authorization surfaces (idiom census)
| Site | Current | Disposition |
|---|---|---|
| `ScriptAnalysis/ScriptAnalysisEndpoints.cs:18` | `RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator,Designer" })` | converge → `ConfigEditor` policy |
| `Alerts.razor:165`, `Shared/Drivers/DriverStatusPanel.razor:166`, `Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor:124`, `Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor:75` | imperative `AuthorizeAsync(…, "DriverOperator")` string literal ×4 | literal → constant (behavior unchanged) |
| `Certificates.razor:90` (`<AuthorizeView Policy="FleetAdmin">`), `:186` (imperative `"FleetAdmin"`) | string literals | literal → constant (behavior unchanged) |
| `Security/ServiceCollectionExtensions.cs:155,160` | `AddPolicy("DriverOperator"…)`, `AddPolicy("FleetAdmin"…)` literals | literal → constant; **add `ConfigEditor` + `AuthenticatedRead`** |
| Hubs (`AlertHub`/`DriverStatusHub`/`FleetStatusHub`/`ScriptLogHub`) `[Authorize]` | read-only feeds | unchanged (out of scope) |
| `Api/DeployApiEndpoints.cs` | `AllowAnonymous` + fixed-time `X-Api-Key` | unchanged (correct, test-pinned) |
**Round-1 plan cross-check:** the `ConfigEditor` policy + reflection-guard proposal in `04-adminui-plan.md` §1 is
sound and retained. Refreshed here: (a) Reservations + ClusterRedundancy move to the read tier (code-verified
above); (b) the four "mixed list pages" need **no** `AuthorizeView` wrapping — they contain only `href` links to
pages that are themselves gated (round 1's option A is unnecessary; a Viewer clicking "New driver" lands on the
`NotAuthorized` slot from `Routes.razor:11-22` — acceptable, and hiding the links is deferred UX polish);
(c) extended to a **single-idiom end state**: every routable page carries an explicit named-policy attribute, so
the guard can enforce "no bare `[Authorize]` anywhere" instead of maintaining an allowed-bare list.
---
## R2-05 (High, 04/C-1) — One authz idiom; gate the mutating surface
### Restatement
Three authorization idioms coexist (bare attribute / role-string literals / named policy), and the largest
mutating surface — `/uns`, equipment, every cluster/node/namespace/ACL editor, all 8 driver pages (which since
#10/#13 author **production-live retry/breaker `ResilienceConfig`**) — carries only bare `[Authorize]`, i.e. the
`RequireAuthenticatedUser` fallback. Any authenticated user, including a read-only **Viewer**, can create, edit,
and delete UNS structure, tags, scripts-adjacent config, driver instances, ACL grants, and live resilience
overrides. Only Deployments/Scripts/ScriptEdit (role strings) and RoleGrants (policy) are gated.
### Verification
Confirmed at `f6eaa267` — see the census above. Nothing is stale; two classification corrections (Reservations,
ClusterRedundancy read-only) and one addition (Home has **no** attribute) were found and are folded into the design.
### Root cause
Pages were authored by copying the nearest neighbor, and the fused Host's `FallbackPolicy` makes bare
`[Authorize]` *look* protective (login is required) while providing zero role separation. No policy-name constants
exist, so the correct gate is undiscoverable and drift is the path of least resistance; nothing in CI observes
which attribute a page carries (no bUnit; razor `@code`/attributes are live-only), so the gap survived every
review until an attribute-metadata census was run.
### Proposed design
**Standardize on ONE idiom — named policies referenced via constants — and make the guard enforce it.**
#### Alternatives considered
| Option | Description | Verdict |
|---|---|---|
| (a) Per-page `Roles="…"` strings | Stamp `Roles = "Administrator,Designer"` on the mutating pages (extend the Deployments idiom) | **Rejected.** Role strings are brittle — the ConfigEditor→Designer / FleetAdmin→Administrator canonical rename already proved the vocabulary moves; every page would encode it. Leaves three idioms in place (RoleGrants keeps policy, imperative sites keep policy names). No compile-time anchor, so the guard would string-match. |
| (b) **Named policies + constants** (recommended) | One `AdminUiPolicies` constants type; a policy per capability tier; every page declares `[Authorize(Policy = AdminUiPolicies.X)]`; imperative/`AuthorizeView`/endpoint sites use the same constants | **Chosen.** Collapses all three idioms into one; the role vocabulary lives in exactly one `AddPolicy` block; constants make gates discoverable and give the reflection guard a compile-time-safe expected map. Matches maintainer posture: global roles, simplest thing that works. |
| (c) Authorize-by-folder convention | Guard infers the required policy from the page's folder (`Pages/Uns/**`, `Pages/Clusters/Drivers/**` → ConfigEditor; `Pages/**` else → read) with no per-page attribute changes beyond the mutating set | **Rejected.** Blazor has no folder-level attribute mechanism, so this is a pure test-side convention: implicit, and a file move silently changes a security gate. Also can't express the real split (read-only `ClusterRedundancy` lives beside editors in `Clusters/`). Explicit per-page attributes + an exhaustive map are the same cost and self-documenting. |
| (d) Tighten the `FallbackPolicy` to `ConfigEditor` | One-line change | **Rejected.** Locks Viewers out of every dashboard; wrong tier for read pages; hides the per-page intent. |
#### Policy set and role mapping
New file `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs` (beside `DevAuthRoles.cs`, namespace
`ZB.MOM.WW.OtOpcUa.Security.Auth`) — **string values of the two existing policies are kept identical to today's
literals** so nothing must change atomically:
```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.54 h): 2 new small files, 1 policy-registration edit, 37 one-line page edits,
7 literal conversions, 2 test files, docs touch, live pass. **Risk: Medium** — wide but shallow blast radius
(every AdminUI page's gate changes); each change is one declarative line; no data-plane impact (OPC UA auth is a
separate LDAP path); the guard + live pass mitigate the two real failure modes (over-gating a read page /
under-gating a mutation page).
**Locked-out-user analysis (what each existing role sees before → after):**
| Role | Before (f6eaa267) | After R2-05 | Change |
|---|---|---|---|
| `Viewer` | every page **including all config mutation** except Deployments/Scripts/ScriptEdit/RoleGrants | the 16 read pages only; denied on all 20 ConfigEditor pages + RoleGrants | **Intended fix** — this is the finding. Viewer keeps all dashboards/lists/live tails. |
| `Designer` | everything except RoleGrants + Certificates mutating actions | identical | No change (ConfigEditor = Administrator+Designer, same set the role-string pages used). |
| `Operator` (appsettings-only) | every page including config mutation (bare-`[Authorize]` hole) + DriverOperator actions | read pages + DriverOperator imperative actions; **newly denied on config pages** | **Deliberate narrowing** — Operator is the live-ops role, not a config author. ⚠ Deployment check: if any real LDAP deployment maps a config-authoring group to `Operator` via `GroupToRole`/RoleGrants, remap it to `Designer` before rollout. |
| `Administrator` | everything | everything | No change. |
| Auto-login dev principal | all roles | all roles (`DevAuthRoles.All` ⊇ Administrator+Designer+Operator) | No dev-rig regression (pinned by test 2). |
| Authenticated, no mapped role | every page incl. mutation (!) | read pages only | Also narrowed, correctly. |
---
## Task breakdown
Branch: `fix/archreview-r2-05-adminui-authz` off `master f6eaa267`. Build gate per commit:
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` green. Conventional commits as noted. Tasks 611 (page edits) are pure
one-line-per-file mechanical edits and are **highly parallelizable** (disjoint files) — if parallel implementers
are used, worktree-isolate (shared-tree git-index race, see memory).
### Task 1 — Add `AdminUiPolicies` constants
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (everything depends on it)
**Files:** create `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs`
Add the four-constant static class exactly as in the design (values `"FleetAdmin"`, `"DriverOperator"`,
`"ConfigEditor"`, `"AuthenticatedRead"`). Build the Security project. No commit yet (commits with Task 4).
### Task 2 — Policy-semantics unit test (RED)
**Classification:** small (TDD red)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3
**Files:** create `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs`
Write the `IAuthorizationService` matrix test per the design (mirror `AddOtOpcUaAuthWiringTests` provider
construction). Include the four-policy matrix + the `DevAuthRoles.All` assertion. Run:
```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 13 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 510)
**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+511).
### Task 12 — Converge non-page literals onto the constants
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 13
**Files:** `ScriptAnalysis/ScriptAnalysisEndpoints.cs` (:18 + comment/xmldoc), `Components/Pages/Alerts.razor`
(:165), `Components/Shared/Drivers/DriverStatusPanel.razor` (:166),
`Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor` (:124),
`Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor` (:75),
`Components/Pages/Certificates.razor` (:90, :186)
Per implementation step 5. Then:
```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.54 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/511, 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.