Merge R2-05 AdminUI authorization (arch-review round 2) [PR #428]
v2-ci / build (push) Successful in 3m27s
v2-ci / unit-tests (push) Failing after 8m29s

Finding 04/C-1 (High): explicit named-policy gating on all 38 AdminUI pages via
new AdminUiPolicies (ConfigEditor + AuthenticatedRead) + anti-drift reflection
guard. T15 live pass deferred (docker-dev auto-admin can't observe deny).
STATUS.md conflict resolved additively. Build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 10:12:57 -04:00
52 changed files with 467 additions and 71 deletions
+3 -2
View File
@@ -220,8 +220,9 @@ help, document formatting, and tag-path completions inside `ctx.GetTag("…")` /
runtime publish gate uses, so what the editor accepts/rejects matches publish runtime publish gate uses, so what the editor accepts/rejects matches publish
exactly. The backend lives in exactly. The backend lives in
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/` (six minimal-API `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/` (six minimal-API
endpoints under `/api/script-analysis/*`, gated to the `Administrator,Designer` endpoints under `/api/script-analysis/*`, gated by the `ConfigEditor` policy
roles via `RequireAuthorization` in `ScriptAnalysisEndpoints.MapScriptAnalysis`). (Administrator or Designer) via `RequireAuthorization` in
`ScriptAnalysisEndpoints.MapScriptAnalysisEndpoints`).
See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` token for equipment-relative tag paths that resolve per-equipment at deploy time — see the "Equipment-relative tag paths" section in `docs/ScriptEditor.md`. See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` token for equipment-relative tag paths that resolve per-equipment at deploy time — see the "Equipment-relative tag paths" section in `docs/ScriptEditor.md`.
## Scripted Alarm Ack/Shelve ## Scripted Alarm Ack/Shelve
+1 -1
View File
@@ -24,7 +24,7 @@
| P-4 (Low) | `Deployments` runs a full config snapshot + hash per page load for the drift badge | **STILL OPEN**`Deployments.razor:98` `SnapshotAndFlattenAsync` | | P-4 (Low) | `Deployments` runs a full config snapshot + hash per page load for the drift badge | **STILL OPEN**`Deployments.razor:98` `SnapshotAndFlattenAsync` |
| P-5 (Low) | `Alerts` rows lack `@key` while prepending; no `<Virtualize>` (bounded by design) | **STILL OPEN**`Alerts.razor:6370` `<tr>` without `@key` | | P-5 (Low) | `Alerts` rows lack `@key` while prepending; no `<Virtualize>` (bounded by design) | **STILL OPEN**`Alerts.razor:6370` `<tr>` without `@key` |
| P-6 (Low) | `inlay-hints` round-trips for a documented no-op | **STILL OPEN**`monaco-init.js:99117` provider active; `ScriptAnalysisService.cs:415` returns empty | | P-6 (Low) | `inlay-hints` round-trips for a documented no-op | **STILL OPEN**`monaco-init.js:99117` provider active; `ScriptAnalysisService.cs:415` returns empty |
| C-1 (High) | Three authorization idioms; the largest mutating surface is bare `[Authorize]` | **STILL OPEN** — re-enumerated every page attribute: only `Deployments`/`Scripts`/`ScriptEdit` carry `Roles="Administrator,Designer"`, only `RoleGrants` carries `Policy="FleetAdmin"`; `GlobalUns`, `EquipmentPage`, all `Clusters/*` editors, all 8 driver pages, and `Reservations` remain bare `[Authorize]`. No remediation branch targeted this (STATUS.md action-list item 5, unscheduled) | | C-1 (High) | Three authorization idioms; the largest mutating surface is bare `[Authorize]` | **FIXED (R2-05, branch `r2/05-adminui-authz`)** — one idiom now: all 38 routable pages carry an explicit named-policy `@attribute` via new `AdminUiPolicies` constants. New `ConfigEditor` (Administrator+Designer) gates the 20-page config-authoring surface incl. all 8 driver pages (live `ResilienceConfig`) + `/api/script-analysis/*`; new `AuthenticatedRead` on the 16 read pages; `FleetAdmin`/`DriverOperator` literals→constants (unchanged semantics). Anti-drift reflection guard `PageAuthorizationGuardTests` + `AdminUiPoliciesTests` semantics matrix (both green). **Two classification corrections vs. this row:** `Reservations` and `ClusterRedundancy` are read-only (→`AuthenticatedRead`, not `ConfigEditor`); `Home` carried no attribute at all (now `AuthenticatedRead`). Offline gate green; docker-dev live positive/regression pass deferred (auto-admin can't observe deny). |
| C-2 (Med) | CLAUDE.md said ScriptAnalysis was `FleetAdmin`-gated; code uses `Roles="Administrator,Designer"` | **FIXED** — docs branch `9fadead6` (merged to master via `b67bd9e8`) corrected CLAUDE.md, which now reads "gated to the `Administrator,Designer` roles via `RequireAuthorization`" (CLAUDE.md:223); code unchanged and still correct | | C-2 (Med) | CLAUDE.md said ScriptAnalysis was `FleetAdmin`-gated; code uses `Roles="Administrator,Designer"` | **FIXED** — docs branch `9fadead6` (merged to master via `b67bd9e8`) corrected CLAUDE.md, which now reads "gated to the `Administrator,Designer` roles via `RequireAuthorization`" (CLAUDE.md:223); code unchanged and still correct |
| C-3 (Good) | Driver-typed tag-editor pattern complete and consistent | **STILL HOLDS** — map/validator/editors unchanged | | C-3 (Good) | Driver-typed tag-editor pattern complete and consistent | **STILL HOLDS** — map/validator/editors unchanged |
| C-4 (Good) | Numeric-enum serialization bug class closed and test-pinned | **STILL HOLDS** — pages + `*FormSerializationTests` unchanged | | C-4 (Good) | Numeric-enum serialization bug class closed and test-pinned | **STILL HOLDS** — pages + `*FormSerializationTests` unchanged |
@@ -573,3 +573,39 @@ record the two verification corrections (Reservations/ClusterRedundancy read-onl
--- ---
**Total: 16 tasks · overall effort Small-Medium (~2.54 h including the live pass).** **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.
@@ -2,21 +2,21 @@
"planPath": "archreview/plans/R2-05-adminui-authz-plan.md", "planPath": "archreview/plans/R2-05-adminui-authz-plan.md",
"lastUpdated": "2026-07-12", "lastUpdated": "2026-07-12",
"tasks": [ "tasks": [
{ "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "pending", "blockedBy": [] }, { "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "completed", "blockedBy": [] },
{ "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "pending", "blockedBy": ["T1"] }, { "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "pending", "blockedBy": ["T1"] }, { "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "pending", "blockedBy": ["T2"] }, { "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "completed", "blockedBy": ["T2"] },
{ "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "pending", "blockedBy": ["T1"] }, { "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "pending", "blockedBy": ["T4", "T5"] }, { "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "pending", "blockedBy": ["T4", "T5"] }, { "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "pending", "blockedBy": ["T4", "T5"] }, { "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "pending", "blockedBy": ["T4", "T5"] }, { "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "pending", "blockedBy": ["T4", "T5"] }, { "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "pending", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] },
{ "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "pending", "blockedBy": ["T11"] }, { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "pending", "blockedBy": ["T11"] }, { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "pending", "blockedBy": ["T12", "T13"] }, { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "completed", "blockedBy": ["T12", "T13"], "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." },
{ "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "pending", "blockedBy": ["T14"] }, { "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "deferred-live", "blockedBy": ["T14"], "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate — controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin)." },
{ "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "pending", "blockedBy": ["T15"] } { "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "completed", "blockedBy": ["T15"] }
] ]
} }
+1
View File
@@ -170,3 +170,4 @@ what the unit leg cannot; (c) migrate the 3 xunit v2 holdouts once Akka ships an
| Plan | Findings | Branch | Commits | Status | | Plan | Findings | Branch | Commits | Status |
|---|---|---|---|---| |---|---|---|---|---|
| **R2-06** | 06/S-11 (rank 7), 06/U-7 (rank 8), 06/C-7 | `r2/06-serverhistorian-failfast` (off `1676c8f4`) | `3a049a13``4640ecb1` (10 task commits) | **Code-complete, offline-verified.** S-11: fail-fast `ServerHistorianOptionsValidator` (consumer-gated on `ServerHistorian:Enabled` OR `AlarmHistorian:Enabled`, fails empty/non-http(s) Endpoint at startup) + `AddValidatedOptions` wiring + adapter defense-in-depth (`TryCreate`→named `InvalidOperationException`, no more raw `UriFormatException` crash-loop) + `Validate()` warning parity + alarm-only-mode startup warning. U-7: pinned Boolean→Int1 explicit-presence (`HasDataType`) + `HISTGW_BOOL_SANDBOX_TAG` live Boolean EnsureTags leg + retype probe (skip-clean offline) + upsert/retype migration note (`docs/Historian.md`). C-7: keep-populating decision + dead-`Quality` doc comments (0.2.0 C-002). **Live leg operator-gated (T12) — pending a VPN run on a 0.2.0 gateway** (records the observed in-place retype policy back into `docs/Historian.md`; also discharges 06/U-2's full documented run). | | **R2-06** | 06/S-11 (rank 7), 06/U-7 (rank 8), 06/C-7 | `r2/06-serverhistorian-failfast` (off `1676c8f4`) | `3a049a13``4640ecb1` (10 task commits) | **Code-complete, offline-verified.** S-11: fail-fast `ServerHistorianOptionsValidator` (consumer-gated on `ServerHistorian:Enabled` OR `AlarmHistorian:Enabled`, fails empty/non-http(s) Endpoint at startup) + `AddValidatedOptions` wiring + adapter defense-in-depth (`TryCreate`→named `InvalidOperationException`, no more raw `UriFormatException` crash-loop) + `Validate()` warning parity + alarm-only-mode startup warning. U-7: pinned Boolean→Int1 explicit-presence (`HasDataType`) + `HISTGW_BOOL_SANDBOX_TAG` live Boolean EnsureTags leg + retype probe (skip-clean offline) + upsert/retype migration note (`docs/Historian.md`). C-7: keep-populating decision + dead-`Quality` doc comments (0.2.0 C-002). **Live leg operator-gated (T12) — pending a VPN run on a 0.2.0 gateway** (records the observed in-place retype policy back into `docs/Historian.md`; also discharges 06/U-2's full documented run). |
| **R2-05** | 04/C-1 (High) | `r2/05-adminui-authz` (off `1676c8f4`) | `183b72b7``6c1f05cb` (5 commits) | **Code-complete, offline-verified.** All 38 routable AdminUI pages carry an explicit named-policy `@attribute` via new `AdminUiPolicies` constants (`Security/Auth/`). New `ConfigEditor` (Administrator+Designer) gates the 20-page config-authoring surface incl. the 8 driver pages that author live `ResilienceConfig` + `/api/script-analysis/*`; new `AuthenticatedRead` on the 16 read pages; `FleetAdmin`/`DriverOperator` unchanged (literals→constants). Anti-drift guard `PageAuthorizationGuardTests` (bans bare `[Authorize]`/`Roles=`/unknown-policy/unclassified) + `AdminUiPoliciesTests` semantics matrix. Corrections folded in: Reservations + ClusterRedundancy read-only (→`AuthenticatedRead`); Home had no attribute (now `AuthenticatedRead`). AdminUI.Tests 517/517, Security.Tests 80/80. **Live `/run` positive pass deferred (T15)** — docker-dev `DisableLogin=true` auto-admin can't observe deny; negative proof is CI-side (both guard tests). |
+14 -1
View File
@@ -295,7 +295,20 @@ The `AdminRole` enum (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.
`DriverOperator` is an **authorization policy name** (kept stable), not an `AdminRole` member. It gates **Reconnect** / **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel` and requires the canonical role `Operator` or `Administrator` (`policy.RequireRole("Operator", "Administrator")` in `AddAuthorization`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). `Operator` is an appsettings-only string role (not an `AdminRole` member); map an LDAP group to it via `GroupToRole`, e.g. `"ot-driver-operator": "Operator"`. The `FleetAdmin` policy requires the `Administrator` role. `DriverOperator` is an **authorization policy name** (kept stable), not an `AdminRole` member. It gates **Reconnect** / **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel` and requires the canonical role `Operator` or `Administrator` (`policy.RequireRole("Operator", "Administrator")` in `AddAuthorization`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). `Operator` is an appsettings-only string role (not an `AdminRole` member); map an LDAP group to it via `GroupToRole`, e.g. `"ot-driver-operator": "Operator"`. The `FleetAdmin` policy requires the `Administrator` role.
In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`), which also installs a `FallbackPolicy` that requires an authenticated user. Razor pages gate inline with the canonical role names, e.g. `@attribute [Authorize(Roles = "Administrator,Designer")]`. Nav-menu sections hide via `<AuthorizeView>`. #### AdminUI page authorization policies (R2-05)
Every routable AdminUI page carries an explicit **named-policy** `@attribute [Authorize(Policy = …)]` — there are no bare `[Authorize]` (authenticated-only) config pages and no per-page `Roles="…"` strings. The policy names are constants on `AdminUiPolicies` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs`), the single source of truth referenced by pages, `AuthorizeView` blocks, imperative `IAuthorizationService.AuthorizeAsync` checks, and endpoint groups (never string literals). All four are registered by `AddOtOpcUaAuth`:
| Policy | Satisfied by roles | Gates |
|---|---|---|
| `AdminUiPolicies.ConfigEditor` | `Administrator`, `Designer` | The config-authoring surface: `/uns`, equipment, cluster/node/namespace/ACL editors, all 8 driver pages (which author live retry/breaker `ResilienceConfig`), Deployments, Scripts, ScriptEdit, and the `/api/script-analysis/*` endpoint group. |
| `AdminUiPolicies.FleetAdmin` | `Administrator` | `RoleGrants` page; the `Certificates` Trust/Untrust/Delete actions (`AuthorizeView` + server-side re-check). |
| `AdminUiPolicies.DriverOperator` | `Operator`, `Administrator` | Imperative live-ops actions only (Alerts ack/shelve, DriverStatusPanel reconnect/restart, live address pickers). |
| `AdminUiPolicies.AuthenticatedRead` | any authenticated user | The read-only dashboards, lists, and live tails (Home, Fleet, Hosts, Alerts, Certificates, cluster read views, etc.). Semantically identical to the `FallbackPolicy`; named so read pages carry a non-bare attribute. |
`Login` is the only `[AllowAnonymous]` page (Admin-001). A reflection guard — `PageAuthorizationGuardTests` (`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs`) — enumerates every routable type in the AdminUI assembly and fails the build if any page carries a bare `[Authorize]`, a `Roles="…"` idiom, an unknown policy name, or is missing from the classification map. This is the anti-drift anchor (the repo has no bUnit): a new page fails the test until its author consciously classifies it. `AdminUiPoliciesTests` (`tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs`) pins each policy's role semantics.
In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`), which also installs a `FallbackPolicy` that requires an authenticated user. Razor pages gate inline with the named-policy attribute above, e.g. `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]`. Nav-menu sections hide via `<AuthorizeView>`.
### Role grant source ### Role grant source
@@ -3,7 +3,7 @@
grants in favour of fleet-wide LDAP-group → role mapping (Q4 of the AdminUI rebuild plan), so grants in favour of fleet-wide LDAP-group → role mapping (Q4 of the AdminUI rebuild plan), so
this version only shows identity + the resolved fleet roles + raw LDAP groups for this version only shows identity + the resolved fleet roles + raw LDAP groups for
troubleshooting. *@ troubleshooting. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@using System.Security.Claims @using System.Security.Claims
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -1,7 +1,7 @@
@page "/alarms-historian" @page "/alarms-historian"
@* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the @* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the
HistorianAdapterActor.GetStatus query landed in F11. *@ HistorianAdapterActor.GetStatus query landed in F11. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Akka.Actor @using Akka.Actor
@using Akka.Hosting @using Akka.Hosting
@@ -2,9 +2,8 @@
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent @* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms) AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms)
and the AB CIP ALMD bridge. *@ and the AB CIP ALMD bridge. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@@ -162,7 +161,7 @@ else
// permitted users. The username is re-read at click time (GetCurrentUserNameAsync) so a // permitted users. The username is re-read at click time (GetCurrentUserNameAsync) so a
// mid-session token refresh lands in the published command + audit accurately. // mid-session token refresh lands in the published command + audit accurately.
var auth = await AuthState.GetAuthenticationStateAsync(); var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded; _canOperate = authResult.Succeeded;
} }
@@ -1,5 +1,5 @@
@page "/certificates" @page "/certificates"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using System.Security.Cryptography.X509Certificates @using System.Security.Cryptography.X509Certificates
@using Microsoft.Extensions.Configuration @using Microsoft.Extensions.Configuration
@@ -87,7 +87,7 @@ else
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected) @if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
{ {
<td> <td>
<AuthorizeView Policy="FleetAdmin"> <AuthorizeView Policy="@AdminUiPolicies.FleetAdmin">
<Authorized> <Authorized>
@if (store.Kind == StoreKind.Rejected) @if (store.Kind == StoreKind.Rejected)
{ {
@@ -183,7 +183,7 @@ else
// Defense-in-depth: the action buttons are FleetAdmin-gated in markup, but this handler // Defense-in-depth: the action buttons are FleetAdmin-gated in markup, but this handler
// runs on the server circuit — re-check the policy before mutating the trust store. // runs on the server circuit — re-check the policy before mutating the trust store.
var authState = await AuthState.GetAuthenticationStateAsync(); var authState = await AuthState.GetAuthenticationStateAsync();
if (!(await AuthorizationService.AuthorizeAsync(authState.User, null, "FleetAdmin")).Succeeded) if (!(await AuthorizationService.AuthorizeAsync(authState.User, null, AdminUiPolicies.FleetAdmin)).Succeeded)
{ {
_statusError = true; _statusError = true;
_statusMsg = "Unauthorized — FleetAdmin required."; _statusMsg = "Unauthorized — FleetAdmin required.";
@@ -1,6 +1,6 @@
@page "/clusters/{ClusterId}/acls/new" @page "/clusters/{ClusterId}/acls/new"
@page "/clusters/{ClusterId}/acls/{NodeAclId}" @page "/clusters/{ClusterId}/acls/{NodeAclId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/acls" @page "/clusters/{ClusterId}/acls"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/audit" @page "/clusters/{ClusterId}/audit"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers" @page "/clusters/{ClusterId}/drivers"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,7 +1,7 @@
@page "/clusters/{ClusterId}/edit" @page "/clusters/{ClusterId}/edit"
@* Edit page for an existing ServerCluster. The /clusters/new route lives in NewCluster.razor; @* Edit page for an existing ServerCluster. The /clusters/new route lives in NewCluster.razor;
this page handles only the update case so the form can disable ClusterId (immutable). *@ this page handles only the update case so the form can disable ClusterId (immutable). *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/namespaces" @page "/clusters/{ClusterId}/namespaces"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}" @page "/clusters/{ClusterId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/redundancy" @page "/clusters/{ClusterId}/redundancy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters" @page "/clusters"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/abcip" @page "/clusters/{ClusterId}/drivers/new/abcip"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/ablegacy" @page "/clusters/{ClusterId}/drivers/new/ablegacy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -2,7 +2,7 @@
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
no registered typed page. *@ no registered typed page. *@
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}" @page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,7 +1,7 @@
@* Driver type picker — presents a card grid of registered driver types and links to the @* Driver type picker — presents a card grid of registered driver types and links to the
per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@ per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
@page "/clusters/{ClusterId}/drivers/new" @page "/clusters/{ClusterId}/drivers/new"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">New driver &middot; <span class="mono">@ClusterId</span></h4> <h4 class="mb-0">New driver &middot; <span class="mono">@ClusterId</span></h4>
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/focas" @page "/clusters/{ClusterId}/drivers/new/focas"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/galaxy" @page "/clusters/{ClusterId}/drivers/new/galaxy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/modbustcp" @page "/clusters/{ClusterId}/drivers/new/modbustcp"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/opcuaclient" @page "/clusters/{ClusterId}/drivers/new/opcuaclient"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/s7" @page "/clusters/{ClusterId}/drivers/new/s7"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/twincat" @page "/clusters/{ClusterId}/drivers/new/twincat"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -3,7 +3,7 @@
@* Live-edit form pattern — one page handles both create (NamespaceId is null) and update. @* Live-edit form pattern — one page handles both create (NamespaceId is null) and update.
RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency
conflicts surface as a toast and reload the current row. *@ conflicts surface as a toast and reload the current row. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/new" @page "/clusters/new"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -2,7 +2,7 @@
@page "/clusters/{ClusterId}/nodes/{NodeId}" @page "/clusters/{ClusterId}/nodes/{NodeId}"
@* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this @* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this
at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@ at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,5 +1,4 @@
@page "/deployments" @page "/deployments"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@@ -9,7 +8,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations @using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations
@attribute [Authorize(Roles = "Administrator,Designer")] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory @inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAdminOperationsClient AdminOps @inject IAdminOperationsClient AdminOps
@@ -3,7 +3,7 @@
progress row owned by each DriverHostActor) and projects the most-recent row per node. The progress row owned by each DriverHostActor) and projects the most-recent row per node. The
Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied
anything yet alongside nodes that have. *@ anything yet alongside nodes that have. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@@ -1,4 +1,5 @@
@page "/" @page "/"
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
<PageTitle>OtOpcUa</PageTitle> <PageTitle>OtOpcUa</PageTitle>
@@ -4,7 +4,7 @@
cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows
are cluster-scoped (keyed per driver instance across the cluster, not per member). The section are cluster-scoped (keyed per driver instance across the cluster, not per member). The section
reads the in-process driver-health snapshot store directly + reloads its config from the ConfigDB. *@ reads the in-process driver-health snapshot store directly + reloads its config from the ConfigDB. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Akka.Actor @using Akka.Actor
@using Akka.Cluster @using Akka.Cluster
@@ -1,5 +1,5 @@
@page "/reservations" @page "/reservations"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/role-grants" @page "/role-grants"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")] @attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@@ -2,7 +2,7 @@
@page "/scripts/{ScriptId}" @page "/scripts/{ScriptId}"
@* Script CRUD. SourceHash is computed automatically from SourceCode on save so the @* Script CRUD. SourceHash is computed automatically from SourceCode on save so the
integrity check in v2's deployment pipeline doesn't require operator action. *@ integrity check in v2's deployment pipeline doesn't require operator action. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Administrator,Designer")] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@@ -1,7 +1,7 @@
@page "/script-log" @page "/script-log"
@* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from @* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from
VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@ VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
@@ -1,5 +1,5 @@
@page "/scripts" @page "/scripts"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Administrator,Designer")] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@@ -4,7 +4,7 @@
the shell + the Details tab (lifted from EquipmentModal's EditForm) + the create→edit redirect; the the shell + the Details tab (lifted from EquipmentModal's EditForm) + the create→edit redirect; the
Tags / Virtual Tags / Alarms tabs render placeholders wired in later tasks. On a successful create the Tags / Virtual Tags / Alarms tabs render placeholders wired in later tasks. On a successful create the
page redirects to /uns/equipment/{newId} so the other tabs (disabled while new) become available. *@ page redirects to /uns/equipment/{newId} so the other tabs (disabled while new) become available. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@@ -1,5 +1,5 @@
@page "/uns" @page "/uns"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
@@ -163,7 +163,7 @@
// The username for audit logging is re-read at button-click time (not captured here) // The username for audit logging is re-read at button-click time (not captured here)
// so token-refreshes mid-session land in audit entries accurately. // so token-refreshes mid-session land in audit entries accurately.
var auth = await AuthState.GetAuthenticationStateAsync(); var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded; _canOperate = authResult.Succeeded;
if (!Enabled) if (!Enabled)
@@ -121,7 +121,7 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var auth = await AuthState.GetAuthenticationStateAsync(); var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded; _canOperate = authResult.Succeeded;
_built = Build(); _built = Build();
await CurrentAddressChanged.InvokeAsync(_built); await CurrentAddressChanged.InvokeAsync(_built);
@@ -72,7 +72,7 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var auth = await AuthState.GetAuthenticationStateAsync(); var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator"); var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded; _canOperate = authResult.Succeeded;
_built = _nodeId; _built = _nodeId;
await CurrentAddressChanged.InvokeAsync(_built); await CurrentAddressChanged.InvokeAsync(_built);
@@ -1,21 +1,21 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public static class ScriptAnalysisEndpoints public static class ScriptAnalysisEndpoints
{ {
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated to the Administrator/Designer roles.</summary> /// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer).</summary>
/// <param name="endpoints">The route builder to map the endpoints onto.</param> /// <param name="endpoints">The route builder to map the endpoints onto.</param>
/// <returns>The same route builder, for chaining.</returns> /// <returns>The same route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints) public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{ {
// Require Administrator or Designer — matches the Script page gate and the /deployments gate. // ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
var group = endpoints.MapGroup("/api/script-analysis") var group = endpoints.MapGroup("/api/script-analysis")
.RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator,Designer" }); .RequireAuthorization(AdminUiPolicies.ConfigEditor);
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r)));
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r))); group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
@@ -1,3 +1,4 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@@ -7,4 +8,5 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
@using ZB.MOM.WW.OtOpcUa.Security.Auth
@using ZB.MOM.WW.Theme @using ZB.MOM.WW.Theme
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
/// <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";
}
@@ -152,12 +152,24 @@ public static class ServiceCollectionExtensions
// are the canonical control-plane roles: Operator (was DriverOperator) and // are the canonical control-plane roles: Operator (was DriverOperator) and
// Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings // Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings
// (e.g. "ot-driver-operator": "Operator"). // (e.g. "ot-driver-operator": "Operator").
o.AddPolicy("DriverOperator", policy => o.AddPolicy(AdminUiPolicies.DriverOperator, policy =>
policy.RequireRole("Operator", "Administrator")); policy.RequireRole(DevAuthRoles.Operator, "Administrator"));
// FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages // FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages
// such as RoleGrants. Requires the canonical Administrator role (was FleetAdmin). // such as RoleGrants. Requires the canonical Administrator role (was FleetAdmin).
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("Administrator")); o.AddPolicy(AdminUiPolicies.FleetAdmin, policy => policy.RequireRole("Administrator"));
// 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());
}); });
return services; return services;
@@ -0,0 +1,186 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Uns;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Authorization;
/// <summary>
/// Reflection guard over every routable AdminUI page's class-level authorization metadata.
/// This is the substitute for bUnit (the repo has no bUnit; razor <c>@attribute</c>/<c>@page</c>
/// compile to class-level attributes, so a metadata scan is authoritative and needs no rendering).
/// It is the anti-drift anchor: a new page fails until the author consciously classifies it.
/// Guard universe = <c>typeof(Routes).Assembly</c> — the exact assembly Routes.razor hands
/// <c>&lt;Router AppAssembly=…&gt;</c>, so the guard sees precisely the router's page set.
/// </summary>
public class PageAuthorizationGuardTests
{
/// <summary>
/// Target classification: every routable page → its required <see cref="AdminUiPolicies"/> policy.
/// This is the 38-page census from the R2-05 plan. <see cref="AnonymousPages"/> holds the sole
/// <c>[AllowAnonymous]</c> page. Together they must exactly cover the router's page set.
/// </summary>
private static readonly IReadOnlyDictionary<Type, string> ExpectedPolicy = new Dictionary<Type, string>
{
// ── ConfigEditor (20): the config-authoring surface (incl. live ResilienceConfig) ──
[typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor,
[typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor,
[typeof(NewCluster)] = AdminUiPolicies.ConfigEditor,
[typeof(ClusterEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(NodeEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(NamespaceEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(AclEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(DriverTypePicker)] = AdminUiPolicies.ConfigEditor,
[typeof(DriverEditRouter)] = AdminUiPolicies.ConfigEditor,
[typeof(ModbusDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(S7DriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(AbCipDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(AbLegacyDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(TwinCATDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(FocasDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(OpcUaClientDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(GalaxyDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(Deployments)] = AdminUiPolicies.ConfigEditor,
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
// ── FleetAdmin (1) ──
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) ──
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ScriptLog)] = AdminUiPolicies.AuthenticatedRead,
[typeof(AlarmsHistorian)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Account)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Certificates)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Reservations)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClustersList)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterOverview)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterAudit)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterAcls)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterNamespaces)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterDrivers)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterRedundancy)] = AdminUiPolicies.AuthenticatedRead,
};
/// <summary>The only page that must remain anonymously reachable (Admin-001 — the login form).</summary>
private static readonly IReadOnlySet<Type> AnonymousPages = new HashSet<Type> { typeof(Login) };
private static readonly string[] KnownPolicyNames =
[
AdminUiPolicies.FleetAdmin,
AdminUiPolicies.DriverOperator,
AdminUiPolicies.ConfigEditor,
AdminUiPolicies.AuthenticatedRead,
];
private static IReadOnlyList<Type> RoutablePages() =>
[.. typeof(Routes).Assembly.GetTypes()
.Where(t => t.GetCustomAttributes<RouteAttribute>(inherit: false).Any())
.OrderBy(t => t.FullName, StringComparer.Ordinal)];
private static string RouteOf(Type t) =>
string.Join(", ", t.GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template));
private static AuthorizeAttribute? Authorize(Type t) =>
t.GetCustomAttributes<AuthorizeAttribute>(inherit: false).SingleOrDefault();
private static bool IsAnonymous(Type t) =>
t.GetCustomAttributes<AllowAnonymousAttribute>(inherit: false).Any();
/// <summary>
/// Rule 1 — no bare gates. Every routable page must carry either <c>[AllowAnonymous]</c> or an
/// <c>[Authorize]</c> with a non-empty Policy and null Roles. Bans bare <c>[Authorize]</c>, the
/// <c>Roles="…"</c> idiom, and attribute-less pages.
/// </summary>
[Fact]
public void EveryRoutablePage_carriesAnExplicitNamedPolicyOrAllowAnonymous()
{
var offenders = new List<string>();
foreach (var page in RoutablePages())
{
if (IsAnonymous(page))
continue;
var auth = Authorize(page);
if (auth is null)
{
offenders.Add($"{page.FullName} ({RouteOf(page)}): no [Authorize]/[AllowAnonymous] attribute");
continue;
}
if (string.IsNullOrEmpty(auth.Policy))
offenders.Add($"{page.FullName} ({RouteOf(page)}): bare [Authorize] — no Policy");
if (!string.IsNullOrEmpty(auth.Roles))
offenders.Add($"{page.FullName} ({RouteOf(page)}): uses Roles=\"{auth.Roles}\" idiom — convert to a named policy");
}
offenders.ShouldBeEmpty(
"Pages must carry an explicit named policy (or [AllowAnonymous]). Offenders:\n" +
string.Join("\n", offenders));
}
/// <summary>
/// Rule 2 — exhaustive classification. The router's page set must equal the expected map plus the
/// anonymous allowlist, and every page's actual policy must match the expected one. A brand-new page
/// fails here until the author classifies it.
/// </summary>
[Fact]
public void EveryRoutablePage_isClassifiedAndMatchesExpectedPolicy()
{
var routable = RoutablePages().ToHashSet();
var classified = new HashSet<Type>(ExpectedPolicy.Keys);
classified.UnionWith(AnonymousPages);
var unclassified = routable.Except(classified)
.Select(t => $"{t.FullName} ({RouteOf(t)})").OrderBy(s => s).ToList();
unclassified.ShouldBeEmpty(
"Routable pages missing from the guard's expected map — classify each in ExpectedPolicy or AnonymousPages:\n" +
string.Join("\n", unclassified));
var stale = classified.Except(routable)
.Select(t => t.FullName ?? t.Name).OrderBy(s => s).ToList();
stale.ShouldBeEmpty(
"Guard expected-map entries that are no longer routable pages — remove them:\n" +
string.Join("\n", stale));
var mismatches = new List<string>();
foreach (var (page, expected) in ExpectedPolicy)
{
var actual = Authorize(page)?.Policy;
if (actual != expected)
mismatches.Add($"{page.FullName} ({RouteOf(page)}): expected policy '{expected}', got '{actual ?? "<none>"}'");
}
mismatches.ShouldBeEmpty(
"Pages whose actual policy differs from the expected classification:\n" +
string.Join("\n", mismatches));
}
/// <summary>Rule 3 — every Policy value in use is one of the four known <see cref="AdminUiPolicies"/> constants.</summary>
[Fact]
public void EveryPolicyNameInUse_isAKnownAdminUiPolicy()
{
var unknown = new List<string>();
foreach (var page in RoutablePages())
{
var policy = Authorize(page)?.Policy;
if (!string.IsNullOrEmpty(policy) && !KnownPolicyNames.Contains(policy))
unknown.Add($"{page.FullName} ({RouteOf(page)}): unknown policy '{policy}'");
}
unknown.ShouldBeEmpty(
"Pages referencing a policy name not defined in AdminUiPolicies:\n" +
string.Join("\n", unknown));
}
}
@@ -0,0 +1,124 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>
/// Pins the semantics of the four AdminUI authorization policies registered by
/// <c>AddOtOpcUaAuth</c>. Mirrors <see cref="AddOtOpcUaAuthWiringTests"/> provider
/// construction (in-memory config, real registration, resolve <see cref="IAuthorizationService"/>).
/// These are the CI-side negative-authz proof: the docker-dev rig auto-authenticates with
/// all roles (DisableLogin=true) so a role-denied path can never be observed live.
/// </summary>
public class AdminUiPoliciesTests
{
private static IAuthorizationService BuildAuthorizationService()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Security:Auth:DisableLogin"] = "false",
}).Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddOtOpcUaAuth(config);
var sp = services.BuildServiceProvider();
return sp.GetRequiredService<IAuthorizationService>();
}
/// <summary>An authenticated principal carrying the given role claims.</summary>
private static ClaimsPrincipal AuthenticatedWith(params string[] roles)
=> new(new ClaimsIdentity(
roles.Select(r => new Claim(ClaimTypes.Role, r)),
authenticationType: "Test"));
/// <summary>An unauthenticated principal (no authenticationType ⇒ IsAuthenticated=false).</summary>
private static ClaimsPrincipal Unauthenticated() => new(new ClaimsIdentity());
private static async Task<bool> AllowedAsync(ClaimsPrincipal user, string policy)
=> (await BuildAuthorizationService().AuthorizeAsync(user, resource: null, policy)).Succeeded;
// ── ConfigEditor: Administrator or Designer ────────────────────────────────────
[Fact]
public async Task ConfigEditor_denies_Viewer()
=> (await AllowedAsync(AuthenticatedWith("Viewer"), AdminUiPolicies.ConfigEditor)).ShouldBeFalse();
[Fact]
public async Task ConfigEditor_denies_Operator()
=> (await AllowedAsync(AuthenticatedWith(DevAuthRoles.Operator), AdminUiPolicies.ConfigEditor)).ShouldBeFalse();
[Fact]
public async Task ConfigEditor_allows_Designer()
=> (await AllowedAsync(AuthenticatedWith("Designer"), AdminUiPolicies.ConfigEditor)).ShouldBeTrue();
[Fact]
public async Task ConfigEditor_allows_Administrator()
=> (await AllowedAsync(AuthenticatedWith("Administrator"), AdminUiPolicies.ConfigEditor)).ShouldBeTrue();
[Fact]
public async Task ConfigEditor_denies_unauthenticated()
=> (await AllowedAsync(Unauthenticated(), AdminUiPolicies.ConfigEditor)).ShouldBeFalse();
// ── AuthenticatedRead: any authenticated user ──────────────────────────────────
[Fact]
public async Task AuthenticatedRead_allows_roleless_authenticated()
=> (await AllowedAsync(AuthenticatedWith(), AdminUiPolicies.AuthenticatedRead)).ShouldBeTrue();
[Fact]
public async Task AuthenticatedRead_allows_Viewer()
=> (await AllowedAsync(AuthenticatedWith("Viewer"), AdminUiPolicies.AuthenticatedRead)).ShouldBeTrue();
[Fact]
public async Task AuthenticatedRead_denies_unauthenticated()
=> (await AllowedAsync(Unauthenticated(), AdminUiPolicies.AuthenticatedRead)).ShouldBeFalse();
// ── FleetAdmin: Administrator only (pre-existing policy, pinned here) ───────────
[Fact]
public async Task FleetAdmin_allows_Administrator()
=> (await AllowedAsync(AuthenticatedWith("Administrator"), AdminUiPolicies.FleetAdmin)).ShouldBeTrue();
[Fact]
public async Task FleetAdmin_denies_Designer()
=> (await AllowedAsync(AuthenticatedWith("Designer"), AdminUiPolicies.FleetAdmin)).ShouldBeFalse();
[Fact]
public async Task FleetAdmin_denies_Operator()
=> (await AllowedAsync(AuthenticatedWith(DevAuthRoles.Operator), AdminUiPolicies.FleetAdmin)).ShouldBeFalse();
// ── DriverOperator: Operator + Administrator only (pre-existing policy, pinned) ─
[Fact]
public async Task DriverOperator_allows_Operator()
=> (await AllowedAsync(AuthenticatedWith(DevAuthRoles.Operator), AdminUiPolicies.DriverOperator)).ShouldBeTrue();
[Fact]
public async Task DriverOperator_allows_Administrator()
=> (await AllowedAsync(AuthenticatedWith("Administrator"), AdminUiPolicies.DriverOperator)).ShouldBeTrue();
[Fact]
public async Task DriverOperator_denies_Designer()
=> (await AllowedAsync(AuthenticatedWith("Designer"), AdminUiPolicies.DriverOperator)).ShouldBeFalse();
[Fact]
public async Task DriverOperator_denies_Viewer()
=> (await AllowedAsync(AuthenticatedWith("Viewer"), AdminUiPolicies.DriverOperator)).ShouldBeFalse();
// ── Dev-rig protection: DevAuthRoles.All satisfies every policy ────────────────
[Theory]
[InlineData(AdminUiPolicies.FleetAdmin)]
[InlineData(AdminUiPolicies.DriverOperator)]
[InlineData(AdminUiPolicies.ConfigEditor)]
[InlineData(AdminUiPolicies.AuthenticatedRead)]
public async Task DevAuthRoles_All_satisfies_every_policy(string policy)
=> (await AllowedAsync(AuthenticatedWith(DevAuthRoles.All), policy)).ShouldBeTrue();
}