Merge R2-05 AdminUI authorization (arch-review round 2) [PR #428]
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:
@@ -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
|
||||
exactly. The backend lives in
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/` (six minimal-API
|
||||
endpoints under `/api/script-analysis/*`, gated to the `Administrator,Designer`
|
||||
roles via `RequireAuthorization` in `ScriptAnalysisEndpoints.MapScriptAnalysis`).
|
||||
endpoints under `/api/script-analysis/*`, gated by the `ConfigEditor` policy
|
||||
(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`.
|
||||
|
||||
## Scripted Alarm Ack/Shelve
|
||||
|
||||
@@ -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-5 (Low) | `Alerts` rows lack `@key` while prepending; no `<Virtualize>` (bounded by design) | **STILL OPEN** — `Alerts.razor:63–70` `<tr>` without `@key` |
|
||||
| P-6 (Low) | `inlay-hints` round-trips for a documented no-op | **STILL OPEN** — `monaco-init.js:99–117` 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-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 |
|
||||
|
||||
@@ -573,3 +573,39 @@ record the two verification corrections (Reservations/ClusterRedundancy read-onl
|
||||
---
|
||||
|
||||
**Total: 16 tasks · overall effort Small-Medium (~2.5–4 h including the live pass).**
|
||||
|
||||
---
|
||||
|
||||
## Execution deviations (R2-05)
|
||||
|
||||
Executed 2026-07-13 on branch `r2/05-adminui-authz` (off master `1676c8f4`, not `f6eaa267` — the plan's
|
||||
stated base; the two commits between are docs-only merges with zero AdminUI source change, so the census
|
||||
re-verified identical). Commits: `183b72b7` (T1/2/4) · `b5bf4b73` (T3/5–11, the gate) · `aac32854` (T12
|
||||
literals) · `dad78351` (T13 docs) · plus this bookkeeping commit (T16).
|
||||
|
||||
- **DriverOperator role string uses `DevAuthRoles.Operator` constant, not the `"Operator"` literal.** In
|
||||
`ServiceCollectionExtensions.cs` the `RequireRole("Operator", …)` now reads `RequireRole(DevAuthRoles.Operator, …)`.
|
||||
Reversible, zero behavior change (same string) — small consistency win beyond the plan's letter, which only
|
||||
called for switching the *policy name* to a constant. `"Administrator"`/`"Designer"` role strings kept literal
|
||||
(no constant exists for them; out of scope).
|
||||
- **Guard test: two page types fully-qualified.** `typeof(Hosts)` and `typeof(Certificates)` collide with
|
||||
sibling namespaces (`…Pages.Hosts`, a folder namespace exists), so those two entries use
|
||||
`typeof(global::…Components.Pages.Hosts)` / `…Certificates`. The other 36 resolve via `@using`. No
|
||||
`<FrameworkReference>` was needed — `RouteAttribute`/`AuthorizeAttribute`/`AllowAnonymousAttribute` all
|
||||
resolved transitively through the AdminUI RCL reference (the plan flagged this as a possible add).
|
||||
- **`MapScriptAnalysis` → `MapScriptAnalysisEndpoints`.** The plan (and the old CLAUDE.md sentence) named the
|
||||
method `MapScriptAnalysis`; the actual method is `MapScriptAnalysisEndpoints`. Corrected the doc references
|
||||
to the real name while converging its literal to `AdminUiPolicies.ConfigEditor`.
|
||||
- **T14 full-solution sweep run once, then constrained off.** A single `dotnet test ZB.MOM.WW.OtOpcUa.slnx`
|
||||
ran before the controller's mid-task constraint (no whole-solution / no `*.IntegrationTests` — heavy suites
|
||||
leak ~16 GB). All failures were triaged as pre-existing/environmental and **outside the R2-05 diff**:
|
||||
Core.Abstractions.Tests `InterfaceIndependence` flags pre-existing `Historian.*` namespace types; a Runtime
|
||||
historian-retry timing test; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host
|
||||
IntegrationTests need Docker fixtures. The authoritative gate is the impacted unit suites — **AdminUI.Tests
|
||||
517/517 and Security.Tests 80/80, both green.** The sweep will not be re-run per the constraint.
|
||||
- **T15 live `/run` deferred-live.** Marked `deferred-live` ("docker-dev live authz /run; serial live pass").
|
||||
Two reasons: (1) it is a heavy integration/browser-drive pass the controller runs serialized; (2) docker-dev
|
||||
`DisableLogin=true` auto-authenticates as a full-access admin, so it can only regression-check over-gating,
|
||||
never observe a role deny — the negative/deny proof is entirely CI-side (T2 + T3, green).
|
||||
- **T16 does not merge.** Executor scope forbids merge/push/PR; the branch is left ready. Bookkeeping (04/C-1
|
||||
row flipped to FIXED, the two read-only classification corrections recorded, STATUS.md R2-05 row) is done.
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
"planPath": "archreview/plans/R2-05-adminui-authz-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "pending", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "pending", "blockedBy": ["T1"] },
|
||||
{ "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "pending", "blockedBy": ["T1"] },
|
||||
{ "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "pending", "blockedBy": ["T2"] },
|
||||
{ "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "pending", "blockedBy": ["T1"] },
|
||||
{ "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "pending", "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": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "pending", "blockedBy": ["T11"] },
|
||||
{ "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "pending", "blockedBy": ["T11"] },
|
||||
{ "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "pending", "blockedBy": ["T12", "T13"] },
|
||||
{ "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": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "pending", "blockedBy": ["T15"] }
|
||||
{ "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": "completed", "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": "completed", "blockedBy": ["T2"] },
|
||||
{ "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": "completed", "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": "completed", "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": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "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": "completed", "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": "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": "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": "completed", "blockedBy": ["T15"] }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|---|---|
|
||||
| **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
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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
|
||||
troubleshooting. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@using System.Security.Claims
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@page "/alarms-historian"
|
||||
@* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the
|
||||
HistorianAdapterActor.GetStatus query landed in F11. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Akka.Actor
|
||||
@using Akka.Hosting
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
|
||||
AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms)
|
||||
and the AB CIP ALMD bridge. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
||||
@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
|
||||
// mid-session token refresh lands in the published command + audit accurately.
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/certificates"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using System.Security.Cryptography.X509Certificates
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@@ -87,7 +87,7 @@ else
|
||||
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
|
||||
{
|
||||
<td>
|
||||
<AuthorizeView Policy="FleetAdmin">
|
||||
<AuthorizeView Policy="@AdminUiPolicies.FleetAdmin">
|
||||
<Authorized>
|
||||
@if (store.Kind == StoreKind.Rejected)
|
||||
{
|
||||
@@ -183,7 +183,7 @@ else
|
||||
// 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.
|
||||
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;
|
||||
_statusMsg = "Unauthorized — FleetAdmin required.";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@page "/clusters/{ClusterId}/acls/new"
|
||||
@page "/clusters/{ClusterId}/acls/{NodeAclId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/acls"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/audit"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@page "/clusters/{ClusterId}/edit"
|
||||
@* 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). *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/namespaces"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/redundancy"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/clusters"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/abcip"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/ablegacy"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
|
||||
no registered typed page. *@
|
||||
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
@* 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}). *@
|
||||
@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">
|
||||
<h4 class="mb-0">New driver · <span class="mono">@ClusterId</span></h4>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/focas"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/galaxy"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/modbustcp"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/opcuaclient"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/s7"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/twincat"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@* 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
|
||||
conflicts surface as a toast and reload the current row. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/clusters/new"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@page "/clusters/{ClusterId}/nodes/{NodeId}"
|
||||
@* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this
|
||||
at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/deployments"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
||||
@@ -9,7 +8,7 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations
|
||||
|
||||
@attribute [Authorize(Roles = "Administrator,Designer")]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject IAdminOperationsClient AdminOps
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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
|
||||
anything yet alongside nodes that have. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
|
||||
<PageTitle>OtOpcUa</PageTitle>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
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
|
||||
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
|
||||
@using Akka.Actor
|
||||
@using Akka.Cluster
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/reservations"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/role-grants"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.Extensions.Options
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@page "/scripts/{ScriptId}"
|
||||
@* Script CRUD. SourceHash is computed automatically from SourceCode on save so the
|
||||
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
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@page "/script-log"
|
||||
@* 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. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/scripts"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Administrator,Designer")]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@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
|
||||
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. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/uns"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@
|
||||
// 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.
|
||||
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;
|
||||
|
||||
if (!Enabled)
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
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;
|
||||
_built = Build();
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
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;
|
||||
_built = _nodeId;
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
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>
|
||||
/// <returns>The same route builder, for chaining.</returns>
|
||||
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")
|
||||
.RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator,Designer" });
|
||||
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
|
||||
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("/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.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@@ -7,4 +8,5 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
|
||||
@using ZB.MOM.WW.OtOpcUa.Security.Auth
|
||||
@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
|
||||
// Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings
|
||||
// (e.g. "ot-driver-operator": "Operator").
|
||||
o.AddPolicy("DriverOperator", policy =>
|
||||
policy.RequireRole("Operator", "Administrator"));
|
||||
o.AddPolicy(AdminUiPolicies.DriverOperator, policy =>
|
||||
policy.RequireRole(DevAuthRoles.Operator, "Administrator"));
|
||||
|
||||
// FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages
|
||||
// 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;
|
||||
|
||||
+186
@@ -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><Router AppAssembly=…></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();
|
||||
}
|
||||
Reference in New Issue
Block a user