Compare commits

...

31 Commits

Author SHA1 Message Date
Joseph Doherty 187fbd7cc8 docs(archreview): sync Status Board — PLAN-07 Complete (32/32 active, T33 skipped); initiative 181/192
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:38:18 -04:00
Joseph Doherty 884f9127b2 test(commons): update AuditKind lock-in test to 16 members (SecuredWriteExpire from T15)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:16:07 -04:00
Joseph Doherty 4887461b64 chore(management): log debug-stream push failures; fix event-log filter docs; refresh stale SourceNode note (arch-review S5/C7/C8)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:07:19 -04:00
Joseph Doherty d6bad1738e fix(management): single-flight transport bundle commands + drop redundant export copy (arch-review S4)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:04:22 -04:00
Joseph Doherty 433c6db4ec feat(cli): browse/search/verify-endpoint/cert-trust commands — actor parity (arch-review C4)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:57:58 -04:00
Joseph Doherty d8a39f3c35 feat(ui): secured-write history paging + submission age in approve dialog (arch-review S2/P3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:57:58 -04:00
Joseph Doherty 655834f0b8 feat(management): additive Skip/Take paging on template/instance lists (arch-review P2)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:57:14 -04:00
Joseph Doherty 8221ee797e fix(management): canonicalize role-mapping casing at write path, closing UI/actor case split (arch-review C5)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:52:45 -04:00
Joseph Doherty 83e09fc210 fix(management): honor MgmtDeployArtifactsCommand.SiteId + enforce site scope (arch-review C2)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:51:03 -04:00
Joseph Doherty 5b00c049f8 test(management): dispatch-coverage test — every registered command has a handler (arch-review UA2)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:43:27 -04:00
Joseph Doherty 61df8a28fe test(management): frozen GetRequiredRoles matrix over every registered command (arch-review UA2)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:43:27 -04:00
Joseph Doherty 8d4ffa7ef1 feat(management): secured-write list paging with TotalCount (arch-review P3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:42:35 -04:00
Joseph Doherty 03295e91bb feat(management): opportunistic expiry sweep on secured-write list (arch-review S2)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:39:29 -04:00
Joseph Doherty 064a7d9415 feat(management): server-side secured-write TTL — stale pending writes expire, never execute (arch-review S2)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:38:19 -04:00
Joseph Doherty b3e2294e1d chore(deploy): ship ManagementService command-timeout config to all central-node topologies (arch-review S1)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:30:39 -04:00
Joseph Doherty 5415e6566f feat(management): Admin-gated actor dispatch for cert-trust commands (arch-review C4)
Made CommunicationService.TrustServerCertAsync/RemoveServerCertAsync/ListServerCertsAsync virtual to support the test seam (mirrors existing virtual WriteTagAsync).

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:29:57 -04:00
Joseph Doherty 722063638b feat(management): actor dispatch for BrowseNode/SearchAddressSpace/VerifyEndpoint — CLI parity (arch-review C4)
Made CommunicationService.BrowseNodeAsync/SearchAddressSpaceAsync/VerifyEndpointAsync virtual to support the test seam (mirrors existing virtual WriteTagAsync).

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:27:48 -04:00
Joseph Doherty 51cff07753 fix(management): reconcile area role gate to Designer|Deployer across actor and all three docs (arch-review C6)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:23:57 -04:00
Joseph Doherty 75bf14a35a chore(ui): memoize AlarmSummary rows + keyboard/aria-sort on sortable headers (arch-review P4/UA6)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:16:40 -04:00
Joseph Doherty 7af36fd5dc feat(security): wire LoginThrottle into management Basic-Auth and /auth/login|token — password-spray guard (arch-review P1/UA5)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:16:40 -04:00
Joseph Doherty d4d1732a9e fix(management): elide DatabaseConnection ConnectionString from List/Get (arch-review C3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:15:55 -04:00
Joseph Doherty ecf8ac1b7d fix(management): elide ExternalSystem AuthConfiguration from responses and audit (arch-review C3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:14:04 -04:00
Joseph Doherty 3a1980cb4c fix(management): elide DataConnection config secrets from responses and audit (arch-review C3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:12:29 -04:00
Joseph Doherty adc488a9f9 fix(ui): reentrancy guard on Health/AlarmSummary poll timers (arch-review S3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:04:18 -04:00
Joseph Doherty bd6c310825 feat(security): LoginThrottle — per-username+IP LDAP-bind failure lockout (arch-review P1)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:04:18 -04:00
Joseph Doherty fb491a5342 feat(commons): additive SiteIdentifier on browse/verify/cert-trust commands for management-API routing (arch-review C4)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:04:18 -04:00
Joseph Doherty 7206761cb4 fix(management): 5-min Ask timeout for transport/deploy commands (arch-review S1)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:04:18 -04:00
Joseph Doherty 0fad6817c2 feat(config-db): secured-write TryMarkExpiredAsync CAS + CountAsync (arch-review S2/P3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:04:18 -04:00
Joseph Doherty 5cfa85ea91 feat(management): ConfigSecretScrubber for connection-config secret elision
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:04:18 -04:00
Joseph Doherty efa6ac223c feat(deployment): DeployToSiteAsync for single-site artifact deployment
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:04:18 -04:00
Joseph Doherty 6c9de9f7ab feat(management): any-of role gates; restrict ListSecuredWrites to Operator/Verifier/Admin (arch-review UA1)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 05:03:51 -04:00
71 changed files with 4567 additions and 293 deletions
+1 -1
View File
@@ -191,7 +191,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
- Cookie+JWT hybrid sessions: HttpOnly/Secure cookie carries an embedded JWT (HMAC-SHA256 shared symmetric key), 15-minute expiry with sliding refresh, 30-minute idle timeout. Cookies are the correct transport for Blazor Server (SignalR circuits).
- LDAP failure: new logins fail; active sessions continue with current roles.
- Load balancer in front of central UI; cookie-embedded JWT + shared Data Protection keys for failover transparency.
- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows currently leave `SourceNode` NULL — a logged follow-up.)
- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.) Pending secured writes expire server-side after a configurable TTL (`ManagementServiceOptions.SecuredWritePendingTtl`, default 24 h): an overdue `Pending` row is CAS'd to `Expired` (never relayed) — enforced at approve/reject and swept opportunistically on list (arch-review S2, `AuditKind.SecuredWriteExpire`).
### Cluster & Failover
- Keep-oldest split-brain resolver with `down-if-alone = on`, 15s stable-after.
+5 -3
View File
@@ -16,7 +16,7 @@ Every plan follows the TDD bite-sized-task format and ships a co-located `.tasks
| [PLAN-04](PLAN-04-data-audit-backbone.md) | Data & Audit Backbone | 26 | 26 | ✅ Complete (all 26 tasks; merged to `main` `dbd3ed2f` via fast-forward; branch removed) | 27 items: 22 fixed, 4 deferred, 1 won't-fix (incl. NodeB reconcile from report 08) |
| [PLAN-05](PLAN-05-templates-deployment-transport.md) | Templates, Deployment & Transport | 25 | 25 | ✅ Complete (all 25 tasks; merged to `main` `ec23db35` via fast-forward; worktree removed, branch deleted) | 33 line-items: 27 fixed, 6 deferred with rationale |
| [PLAN-06](PLAN-06-edge-integrations.md) | Edge Integrations | 24 | 24 | ✅ Complete (all 24 tasks; merged to `main` `3e964ac2` via fast-forward; worktree removed, branch deleted) | 24 findings: 21 fixed, 3 deferred/won't-fix |
| [PLAN-07](PLAN-07-ui-management-security.md) | UI, Management & Security | 33 | 3 | 🟨 In progress (P0: T1, T2, T3) | 24 items: 20 fixed, 3 partial (deferrals documented), 1 won't-fix |
| [PLAN-07](PLAN-07-ui-management-security.md) | UI, Management & Security | 33 | 32 | ✅ Complete (all 32 active tasks; T33 `skipped-duplicate` — CLI.Tests already in slnx via PLAN-08 T1; merged to `main` via fast-forward; worktree removed, branch deleted) | 24 items: 20 fixed, 3 partial (deferrals documented), 1 won't-fix |
| [PLAN-08](PLAN-08-conventions-tests.md) | Conventions & Tests | 11 | 1 | 🟨 In progress (P0: T1) | 13 covered, 4 owned by other plans, ~20 triaged into deferred-work register |
Status values: ⬜ Not started · 🟨 In progress · ✅ Complete · ⏸ Blocked
@@ -35,9 +35,11 @@ Status values: ⬜ Not started · 🟨 In progress · ✅ Complete · ⏸ Blocke
**Wave 4 — PLAN-06 COMPLETE (2026-07-10):** all 24 tasks delivered and **merged to `main` `3e964ac2` via fast-forward** (main was still at PLAN-05's `9b0cf44c`, zero divergence; worktree removed, branch deleted). Edge Integrations hardening across six report-and-wait waves of file-disjoint parallel subagents: the **InboundAPI** spine — revision-checked compiled-handler cache (heals failover / direct-SQL / known-bad staleness, DB row now authoritative — T1/T2), `InvalidateMethod` seam consuming PLAN-05's `IScriptArtifactChangeBus` contract (T3), deferred DI-scope disposal + orphan-execution telemetry (T4), machine-readable error `code`s incl. `SITE_UNREACHABLE` + 415-on-non-JSON (T5), `IOptionsMonitor` hot-reload (T6), parallel startup compile (T7); the **ESG** per-system `TimeoutSeconds` full vertical slice (entity→EF migration→artifact→site SQLite→client honor→mgmt→CLI→UI→doc — T8–T12) plus `{param}` path-template substitution (T13), bounded outbound response buffering (T14), structured JSON `AuthConfiguration` with legacy-colon fallback (T15), cancellation-aware `IsTransient` (T16); the **Notification** batch — SMS first-transient short-circuit (T17), wired the dead `SmsConfiguration.MaxRetries/RetryDelay` per-type retry policy (T18), configurable OAuth2 authority/scope (entity→EF migration→token service→mgmt/CLI/UI surfaces — T19/T20), deterministic lowest-Id SMTP selection + atomic OAuth2 token cache (T21), Twilio `AccountSid` format validation (T22); and the docs (DelmiaNotifier at-least-once semantics — T23; dual-key API-key rotation procedure — T24). Two EF migrations added (`AddExternalSystemTimeoutSeconds` T8, `AddSmtpOAuth2AuthorityScope` T19, serialized on the shared snapshot). **Terminal verification:** full slnx build 0 warn/0 err; every unit + integration suite green (InboundAPI 253, ESG 132, NotificationOutbox 132, NotificationService 57, ManagementService 245, CLI 346, CentralUI 893, SiteRuntime 494, Commons 597, ConfigDB 338, Host 260, + all others). Only residue: the same environmental 8 `CentralUI.PlaywrightTests` Deployment E2E failures (`SQLite Error 14` on `instance deploy` — needs a live docker cluster; PLAN-06 touched zero files in that project's deployment-DB path). Execution note: `isolation:"worktree"` subagents proved non-isolating here (shared index → commit races); the working model was pre-created dedicated `git worktree`s per task + cherry-pick onto an integration branch + per-wave combined build/impacted-suite barrier. Initiative total: **152 of 192** (sum of the Done column).
**Wave 5 — PLAN-07 COMPLETE (2026-07-10):** all 32 active tasks (T4–T32; T1–T3 landed in P0; **T33 `skipped-duplicate`** — CLI.Tests already in the slnx via PLAN-08 T1) delivered on worktree branch `plan07` and **merged to `main` via fast-forward** (main was still at PLAN-06's `4fb754f9`, zero divergence; all worktrees removed, branch deleted). UI/Management/Security hardening across six report-and-wait waves of file-disjoint parallel subagents against the dominant **ManagementActor single-writer lane** (15 tasks serialized in one integration worktree; the file-disjoint remainder ran in dedicated per-task worktrees, cherry-picked in with a per-wave combined build + impacted-suite barrier): **C1** auth-disabled artifact killed + `DisableLoginGuard` fail-fast (T1–T3, P0); **C2** `DeployToSiteAsync` single-site deploy + fleet-wide site-scope guard (T4/T5); **C3** connection/external-system/DB-connection secret elision via `ConfigSecretScrubber` + sentinel-preserving update (T6–T9); **UA1/C6/C5** any-of role gates + `ListSecuredWrites` gating + area-gate reconciliation (Designer|Deployer) + role-casing canonicalization (T10–T12); **UA2** the frozen `GetRequiredRoles` authorization matrix over all **138** registered commands + dispatch-coverage guard (T13/T24); **S2/P3** real server-side secured-write TTL + expiry-at-approve/reject + opportunistic list sweep + list paging + UI history pager & submission-age dialog (T14–T18); **S1** 5-min transport/deploy Ask timeout + shipped config (T19/T20); **C4** additive `SiteIdentifier` on browse/verify/cert commands + actor dispatch (Designer browse/verify, Admin cert-trust) + dispatch-coverage + CLI parity (T21–T25); **P1/UA5** `LoginThrottle` per-username+IP LDAP-bind lockout wired into every bind surface (T26/T27); **P2** additive Skip/Take paging on template/instance lists (T28); **S3** `PollGate` reentrancy guard on Health/AlarmSummary timers (T29); **S4** transport single-flight + export base64-copy elimination (T30); **S5/C7/C8/P4/UA6** debug-stream fault logging + event-log-filter doc fix + AlarmSummary memoization/a11y + the stale CLAUDE.md `SourceNode` note corrected (C7/C8 both verified true — T31/T32). Two message-contract additions (`AuditKind.SecuredWriteExpire`, secured-write list paging params) landed additively; no EF migrations needed. **Terminal verification:** full slnx build 0 warn/0 err; 28 of 30 test assemblies green (ManagementService 448 incl. the frozen matrix + dispatch-coverage, CentralUI 898, CLI 359, Security 175, Commons 609, Communication 263, Transport 138, ConfigDB 341, SiteRuntime 494, Host 260, + all others), after fixing one stale enum-count lock-in test (`AuditEnumTests` 15→16 for T15's additive `SecuredWriteExpire` — the per-task filtered runs never hit that Commons guard, so the full sweep caught it, exactly as in PLAN-04). Only residue: the same environmental 8 `CentralUI.PlaywrightTests` Deployment E2E failures (`SQLite Error 14` on `instance deploy` — needs a live docker cluster; PLAN-07 touched zero files in that project's deployment-DB path). Initiative total: **181 of 192** (sum of the Done column; PLAN-07's max is 32/33 with T33 a documented skip).
## Parallelization Map — Incomplete Tasks (2026-07-09)
Covers the plans with open tasks: PLAN-07 (30), PLAN-08 (10). PLAN-01/02/03/04/05/06 are complete — nothing to parallelize there (PLAN-05 finished 2026-07-10; PLAN-06 finished 2026-07-10; the section below is retained for its cross-plan mutex analysis, which still governs PLAN-07–08). **"Ready"** = every `blockedBy` entry is already `completed` in the plan's `.tasks.json`, so the task can start now. Ready sets are recomputed below from the live manifests; re-derive after each merge.
Covers the plans with open tasks: PLAN-08 (10). PLAN-01/02/03/04/05/06/07 are complete — nothing to parallelize there (PLAN-05 finished 2026-07-10; PLAN-06 finished 2026-07-10; PLAN-07 finished 2026-07-10; the section below is retained for its cross-plan mutex analysis, which still governs PLAN-08 and documents how PLAN-07's ManagementActor lane was run). **"Ready"** = every `blockedBy` entry is already `completed` in the plan's `.tasks.json`, so the task can start now. Ready sets are recomputed below from the live manifests; re-derive after each merge.
### Cross-plan serialization mutexes (a task holding one must be the ONLY task touching that surface, anywhere)
@@ -59,7 +61,7 @@ Covers the plans with open tasks: PLAN-07 (30), PLAN-08 (10). PLAN-01/02/03/04/0
- **PLAN-04** — ✅ COMPLETE (2026-07-09); all 26 tasks done, merged to `main` `dbd3ed2f`. No longer a parallelization lane.
- **PLAN-05** — ✅ COMPLETE (2026-07-10); all 25 tasks done, merged to `main` `ec23db35` (fast-forward). No longer a parallelization lane. (Historical: the BundleImporter serial chain T1→2→…→6→19→20→23 ran in-worktree; T14's `IScriptArtifactChangeBus` contract is shipped for PLAN-06 to consume.)
- **PLAN-06** — ✅ COMPLETE (2026-07-10); all 24 tasks done, merged to `main` `3e964ac2` (fast-forward). No longer a parallelization lane. (Historical: ran as 6 waves — W1{1,6,7,8,17,23} W2{2,9,10,12,18,19} W3{3,13,21} W4{4,11,14} W5{5,15,20} W6{16,22,24}; the ExternalSystemClient chain T9→13→14→15→16 and inbound-executor chain T1→3→4→5 were the two serial spines.)
- **PLAN-07** — ready non-ManagementActor (mutually concurrent, file-disjoint): **{4, 6, 14, 19, 21, 26, 29 | 32}** (ArtifactDeploymentService / ConfigSecretScrubber / SecuredWriteRepository / ManagementEndpoints+options / Commons Browse-Cert message records / Security LoginThrottle / CentralUI PollGate — T29 and T32 share `AlarmSummary.razor`, so serialize those two). Ready **ManagementActor** tasks (→ the ONE global Lane M): **{8, 9, 10, 12, 28, 30, 31}** — **start with T10** (it unblocks T11, T13, T22, T23). **T33 = skip (dedupe).**
- **PLAN-07** — ✅ COMPLETE (2026-07-10); all 32 active tasks done, merged to `main` (fast-forward); T33 `skipped-duplicate`. No longer a parallelization lane. (Historical: ran as 6 waves — W1{4,6,10,14,19,21,26,29} W2{7,8,9,27,32} W3{11,20,22,23} W4{13,15,16,17,24} W5{5,12,18,25,28} W6{30,31}; Lane M = the ManagementActor single-writer chain T10→7→8→9→11→22→23→15→16→17→5→12→28→30→31 driven serially in the integration worktree, with the file-disjoint remainder in dedicated per-task worktrees cherry-picked in per wave.)
- **PLAN-08** — ready: **{2, 3, 4, 5, 7, 8, 9, 11}** — each is a new per-component validator file or an isolated test/doc, so **mutually parallel**, with one caveat: **T4 shares `Host/Program.cs` with P06 T7** (sequence them). This is the initiative's single highest-parallelism slice (independent validator files). T6, T10 stay blocked.
### Recommended concurrent execution
@@ -1,39 +1,241 @@
{
"planPath": "archreview/plans/PLAN-07-ui-management-security.md",
"tasks": [
{ "id": 1, "subject": "Task 1: DisableLoginGuard — refuse the flag outside Development", "status": "completed", "blockedBy": [] },
{ "id": 2, "subject": "Task 2: Wire the guard at the Host composition root + doc", "status": "completed", "blockedBy": [1] },
{ "id": 3, "subject": "Task 3: Scrub deploy/wonder-app-vd03 — remove DisableLogin: true (on-disk only; deploy/ is gitignored)", "status": "completed", "blockedBy": [] },
{ "id": 4, "subject": "Task 4: ArtifactDeploymentService.DeployToSiteAsync", "status": "pending", "blockedBy": [] },
{ "id": 5, "subject": "Task 5: HandleDeployArtifacts honors SiteId + site-scope enforcement + docs", "status": "pending", "blockedBy": [4] },
{ "id": 6, "subject": "Task 6: ConfigSecretScrubber — JSON secret elision + sentinel merge", "status": "pending", "blockedBy": [] },
{ "id": 7, "subject": "Task 7: DataConnection List/Get/audit secret elision + sentinel-preserving update", "status": "pending", "blockedBy": [6] },
{ "id": 8, "subject": "Task 8: ExternalSystem AuthConfiguration elision + preserve-if-null update", "status": "pending", "blockedBy": [] },
{ "id": 9, "subject": "Task 9: DatabaseConnection ConnectionString elision + preserve-if-null update", "status": "pending", "blockedBy": [] },
{ "id": 10, "subject": "Task 10: GetRequiredRoles any-of refactor + gate ListSecuredWrites", "status": "pending", "blockedBy": [] },
{ "id": 11, "subject": "Task 11: Reconcile the area-management role gate (code + three docs)", "status": "pending", "blockedBy": [10] },
{ "id": 12, "subject": "Task 12: Canonicalize role-mapping casing at the write path", "status": "pending", "blockedBy": [] },
{ "id": 13, "subject": "Task 13: GetRequiredRoles matrix test — freeze the authorization table", "status": "pending", "blockedBy": [10, 11, 22, 23] },
{ "id": 14, "subject": "Task 14: SecuredWrite repository — TryMarkExpiredAsync + CountAsync", "status": "pending", "blockedBy": [] },
{ "id": 15, "subject": "Task 15: Server-side secured-write TTL — enforce at approve/reject", "status": "pending", "blockedBy": [14] },
{ "id": 16, "subject": "Task 16: Opportunistic expiry sweep in HandleListSecuredWrites", "status": "pending", "blockedBy": [15] },
{ "id": 17, "subject": "Task 17: Secured-write list paging — command, handler, CLI", "status": "pending", "blockedBy": [14] },
{ "id": 18, "subject": "Task 18: SecuredWrites page — history pager + age in approve dialog + docs", "status": "pending", "blockedBy": [17] },
{ "id": 19, "subject": "Task 19: Per-command long-running Ask timeout", "status": "pending", "blockedBy": [] },
{ "id": 20, "subject": "Task 20: Ship ManagementService timeout config + doc", "status": "pending", "blockedBy": [19] },
{ "id": 21, "subject": "Task 21: Browse/Verify/Cert commands gain optional SiteIdentifier", "status": "pending", "blockedBy": [] },
{ "id": 22, "subject": "Task 22: Actor handlers — BrowseNode / SearchAddressSpace / VerifyEndpoint", "status": "pending", "blockedBy": [10, 21] },
{ "id": 23, "subject": "Task 23: Actor handlers — TrustServerCert / RemoveServerCert / ListServerCerts (Admin)", "status": "pending", "blockedBy": [10, 21] },
{ "id": 24, "subject": "Task 24: Dispatch-coverage test — every registered command reaches a handler", "status": "pending", "blockedBy": [22, 23] },
{ "id": 25, "subject": "Task 25: CLI parity — browse / search / verify-endpoint / certs commands", "status": "pending", "blockedBy": [22, 23] },
{ "id": 26, "subject": "Task 26: LoginThrottle — fixed-window LDAP-bind failure lockout", "status": "pending", "blockedBy": [] },
{ "id": 27, "subject": "Task 27: Wire the throttle — ManagementAuthenticator + auth endpoints + doc", "status": "pending", "blockedBy": [26] },
{ "id": 28, "subject": "Task 28: Additive paging for ListTemplates / ListInstances", "status": "pending", "blockedBy": [] },
{ "id": 29, "subject": "Task 29: PollGate — non-reentrant poll timers on Health + Alarm Summary", "status": "pending", "blockedBy": [] },
{ "id": 30, "subject": "Task 30: Transport single-flight + base64 copy elimination", "status": "pending", "blockedBy": [] },
{ "id": 31, "subject": "Task 31: Low cleanup A — hub send fault logging, event-log filter comment, stale CLAUDE.md note", "status": "pending", "blockedBy": [] },
{ "id": 32, "subject": "Task 32: Low cleanup B — AlarmSummary memoization + sortable-header a11y", "status": "pending", "blockedBy": [] },
{ "id": 33, "subject": "Task 33: Add CLI.Tests to the slnx + retire the gotcha", "status": "pending", "blockedBy": [] }
{
"id": 1,
"subject": "Task 1: DisableLoginGuard \u2014 refuse the flag outside Development",
"status": "completed",
"blockedBy": []
},
{
"id": 2,
"subject": "Task 2: Wire the guard at the Host composition root + doc",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3: Scrub deploy/wonder-app-vd03 \u2014 remove DisableLogin: true (on-disk only; deploy/ is gitignored)",
"status": "completed",
"blockedBy": []
},
{
"id": 4,
"subject": "Task 4: ArtifactDeploymentService.DeployToSiteAsync",
"status": "completed",
"blockedBy": []
},
{
"id": 5,
"subject": "Task 5: HandleDeployArtifacts honors SiteId + site-scope enforcement + docs",
"status": "completed",
"blockedBy": [
4
]
},
{
"id": 6,
"subject": "Task 6: ConfigSecretScrubber \u2014 JSON secret elision + sentinel merge",
"status": "completed",
"blockedBy": []
},
{
"id": 7,
"subject": "Task 7: DataConnection List/Get/audit secret elision + sentinel-preserving update",
"status": "completed",
"blockedBy": [
6
]
},
{
"id": 8,
"subject": "Task 8: ExternalSystem AuthConfiguration elision + preserve-if-null update",
"status": "completed",
"blockedBy": []
},
{
"id": 9,
"subject": "Task 9: DatabaseConnection ConnectionString elision + preserve-if-null update",
"status": "completed",
"blockedBy": []
},
{
"id": 10,
"subject": "Task 10: GetRequiredRoles any-of refactor + gate ListSecuredWrites",
"status": "completed",
"blockedBy": []
},
{
"id": 11,
"subject": "Task 11: Reconcile the area-management role gate (code + three docs)",
"status": "completed",
"blockedBy": [
10
]
},
{
"id": 12,
"subject": "Task 12: Canonicalize role-mapping casing at the write path",
"status": "completed",
"blockedBy": []
},
{
"id": 13,
"subject": "Task 13: GetRequiredRoles matrix test \u2014 freeze the authorization table",
"status": "completed",
"blockedBy": [
10,
11,
22,
23
]
},
{
"id": 14,
"subject": "Task 14: SecuredWrite repository \u2014 TryMarkExpiredAsync + CountAsync",
"status": "completed",
"blockedBy": []
},
{
"id": 15,
"subject": "Task 15: Server-side secured-write TTL \u2014 enforce at approve/reject",
"status": "completed",
"blockedBy": [
14
]
},
{
"id": 16,
"subject": "Task 16: Opportunistic expiry sweep in HandleListSecuredWrites",
"status": "completed",
"blockedBy": [
15
]
},
{
"id": 17,
"subject": "Task 17: Secured-write list paging \u2014 command, handler, CLI",
"status": "completed",
"blockedBy": [
14
]
},
{
"id": 18,
"subject": "Task 18: SecuredWrites page \u2014 history pager + age in approve dialog + docs",
"status": "completed",
"blockedBy": [
17
]
},
{
"id": 19,
"subject": "Task 19: Per-command long-running Ask timeout",
"status": "completed",
"blockedBy": []
},
{
"id": 20,
"subject": "Task 20: Ship ManagementService timeout config + doc",
"status": "completed",
"blockedBy": [
19
]
},
{
"id": 21,
"subject": "Task 21: Browse/Verify/Cert commands gain optional SiteIdentifier",
"status": "completed",
"blockedBy": []
},
{
"id": 22,
"subject": "Task 22: Actor handlers \u2014 BrowseNode / SearchAddressSpace / VerifyEndpoint",
"status": "completed",
"blockedBy": [
10,
21
]
},
{
"id": 23,
"subject": "Task 23: Actor handlers \u2014 TrustServerCert / RemoveServerCert / ListServerCerts (Admin)",
"status": "completed",
"blockedBy": [
10,
21
]
},
{
"id": 24,
"subject": "Task 24: Dispatch-coverage test \u2014 every registered command reaches a handler",
"status": "completed",
"blockedBy": [
22,
23
]
},
{
"id": 25,
"subject": "Task 25: CLI parity \u2014 browse / search / verify-endpoint / certs commands",
"status": "completed",
"blockedBy": [
22,
23
]
},
{
"id": 26,
"subject": "Task 26: LoginThrottle \u2014 fixed-window LDAP-bind failure lockout",
"status": "completed",
"blockedBy": []
},
{
"id": 27,
"subject": "Task 27: Wire the throttle \u2014 ManagementAuthenticator + auth endpoints + doc",
"status": "completed",
"blockedBy": [
26
]
},
{
"id": 28,
"subject": "Task 28: Additive paging for ListTemplates / ListInstances",
"status": "completed",
"blockedBy": []
},
{
"id": 29,
"subject": "Task 29: PollGate \u2014 non-reentrant poll timers on Health + Alarm Summary",
"status": "completed",
"blockedBy": []
},
{
"id": 30,
"subject": "Task 30: Transport single-flight + base64 copy elimination",
"status": "completed",
"blockedBy": []
},
{
"id": 31,
"subject": "Task 31: Low cleanup A \u2014 hub send fault logging, event-log filter comment, stale CLAUDE.md note",
"status": "completed",
"blockedBy": []
},
{
"id": 32,
"subject": "Task 32: Low cleanup B \u2014 AlarmSummary memoization + sortable-header a11y",
"status": "completed",
"blockedBy": []
},
{
"id": 33,
"subject": "Task 33: Add CLI.Tests to the slnx + retire the gotcha",
"status": "skipped-duplicate",
"blockedBy": []
}
],
"lastUpdated": "2026-07-08"
"lastUpdated": "2026-07-10"
}
@@ -45,6 +45,10 @@
"TransportFailureThreshold": "00:00:15",
"CentralFetchBaseUrl": "http://scadabridge-env2-traefik"
},
"ManagementService": {
"CommandTimeout": "00:00:30",
"LongRunningCommandTimeout": "00:05:00"
},
"HealthMonitoring": {
"ReportInterval": "00:00:30",
"OfflineTimeout": "00:01:00"
@@ -45,6 +45,10 @@
"TransportFailureThreshold": "00:00:15",
"CentralFetchBaseUrl": "http://scadabridge-env2-traefik"
},
"ManagementService": {
"CommandTimeout": "00:00:30",
"LongRunningCommandTimeout": "00:05:00"
},
"HealthMonitoring": {
"ReportInterval": "00:00:30",
"OfflineTimeout": "00:01:00"
@@ -48,6 +48,10 @@
"TransportFailureThreshold": "00:00:15",
"CentralFetchBaseUrl": "http://scadabridge-traefik"
},
"ManagementService": {
"CommandTimeout": "00:00:30",
"LongRunningCommandTimeout": "00:05:00"
},
"HealthMonitoring": {
"ReportInterval": "00:00:30",
"OfflineTimeout": "00:01:00"
@@ -48,6 +48,10 @@
"TransportFailureThreshold": "00:00:15",
"CentralFetchBaseUrl": "http://scadabridge-traefik"
},
"ManagementService": {
"CommandTimeout": "00:00:30",
"LongRunningCommandTimeout": "00:05:00"
},
"HealthMonitoring": {
"ReportInterval": "00:00:30",
"OfflineTimeout": "00:01:00"
+38 -2
View File
@@ -56,7 +56,7 @@ tree at the time of writing.
### Template Commands
```
scadabridge template list
scadabridge template list [--detail] [--skip <n>] [--take <n>]
scadabridge template get --id <id>
scadabridge template create --name <name> [--description <desc>] [--parent-id <id>]
scadabridge template update --id <id> [--name <name>] [--description <desc>] [--parent-id <id>]
@@ -80,7 +80,7 @@ scadabridge template native-alarm-source remove --id <id>
### Instance Commands
```
scadabridge instance list [--site-id <id>] [--template-id <id>] [--search <term>]
scadabridge instance list [--site-id <id>] [--template-id <id>] [--search <term>] [--skip <n>] [--take <n>]
scadabridge instance get --id <id>
scadabridge instance create --name <name> --template-id <id> --site-id <id> [--area-id <id>]
scadabridge instance set-bindings --id <id> --bindings <json>
@@ -162,6 +162,8 @@ scadabridge deploy artifacts [--site-id <id>]
scadabridge deploy status [--instance-id <id>] [--status <status>] [--page <n>] [--page-size <n>]
```
`deploy artifacts` (and its alias `site deploy-artifacts`): `--site-id` is now honored — supplying it deploys system-wide artifacts to exactly that site; omitting it deploys fleet-wide to every site. A site-scoped (non-Administrator) Deployer must supply an in-scope `--site-id`; fleet-wide deployment (no `--site-id`) requires a system-wide Deployer or Administrator (arch-review C2).
### Data Connection Commands
```
scadabridge data-connection list [--site-id <id>]
@@ -171,6 +173,23 @@ scadabridge data-connection update --id <id> --name <name> --protocol <protocol>
scadabridge data-connection delete --id <id>
```
#### Site-routed OPC UA operations (browse / search / verify-endpoint / certs)
CLI parity for the site-routed data-connection operations the ManagementActor dispatches
(arch-review C4). Each carries `--site` so the management API routes it to the target
site cluster. `browse`/`search`/`verify-endpoint` require the **Designer** role; `certs`
require **Admin**. `verify-endpoint` reads `ConfigJson` verbatim from `--config-file`;
`certs trust` base64-encodes `--der-file`.
```
scadabridge data-connection browse --site <identifier> --connection <name> [--node-id <nodeId>]
scadabridge data-connection search --site <identifier> --connection <name> --query <text> [--max-depth <n>] [--max-results <n>]
scadabridge data-connection verify-endpoint --site <identifier> --protocol <protocol> --config-file <path> [--connection <name>]
scadabridge data-connection certs list --site <identifier> [--connection <name>]
scadabridge data-connection certs trust --site <identifier> --connection <name> --der-file <path> --thumbprint <hex>
scadabridge data-connection certs remove --site <identifier> --thumbprint <hex> [--connection <name>]
```
### External System Commands
```
scadabridge external-system list
@@ -354,6 +373,23 @@ The `--tracked-operation-id` value corresponds to the `MessageId` field of the p
`SiteCall` row (visible on the Central UI Site Calls page and in the `SiteCalls` audit
table).
### Secured-Write Commands (two-person MxGateway writes)
Query two-person (MxGateway) secured writes — the pending queue plus terminal history.
Read-only; gated any-of **Operator** / **Verifier** / **Administrator** (the history
exposes process-sensitive tag values, so it is not open to every authenticated user).
```
scadabridge secured-write list [--status <status>] [--site <identifier>] [--skip <n>] [--take <n>]
```
`--status` / `--site` filter the results (omit to match all). `--skip` / `--take` provide
offset paging (arch-review P3); omitting them uses the historical unpaged default
(`--skip 0 --take 200`). `--take` is clamped to `1..500` server-side. The result carries a
`totalCount` (rows matching the filters, ignoring paging) alongside the returned page.
Overdue `Pending` rows are swept to `Expired` opportunistically each time the list is
queried (server-side TTL, arch-review S2).
The `--format json|table` option is recursive and accepted on every command above.
## Configuration
+6 -4
View File
@@ -103,7 +103,8 @@ Central cluster only. Sites have no user interface.
- `Export Bundle` (`/design/transport/export`) — multi-step wizard for exporting a `.scadabundle` artifact containing templates, shared scripts, external systems, and central-only configuration. Visible to users with `RequireDesign`.
- `Import Bundle` (`/design/transport/import`) — multi-step wizard for uploading and applying a `.scadabundle` into the current environment, with per-artifact diff review and conflict resolution. Visible to users with `RequireAdmin`.
### Area Management (Admin Role)
### Area Management (Designer or Deployer Role)
- Areas are authored inside the Deployment Topology workflow (`Topology.razor`, `RequireDeployment`); managing them is gated **any-of [Designer, Deployer]**, not Administrator (arch-review C6).
- Define hierarchical area structures per site.
- Parent-child area relationships.
- Assign areas when managing instances.
@@ -206,11 +207,12 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
- **Stuck rows are visually badged** — a notification is stuck if it is `Pending` or `Retrying` and older than the configurable stuck-age threshold. Stuck detection is display-only; there is no automated escalation or alerting.
- All queries are served from the central `Notifications` table — no remote per-site queries are needed, unlike the Parked Message Management page.
### Secured Writes (Operator / Verifier Roles)
### Secured Writes (Operator / Verifier / Administrator Roles)
- A **Secured Writes** page (`/operations/secured-writes`) drives the **two-person** authorization workflow for writes through the MxAccess Gateway: an **Operator** initiates the write, a separate **Verifier** approves it, and only an approved write reaches the site.
- The **page itself is gated by `RequireSecuredWriteAccess`** — satisfied by any of `Operator` / `Verifier` / `Administrator`. Although the pending/history lists are read-only, they expose process-sensitive tag values, so the page is not open to every authenticated user; this aligns with the ManagementActor `ListSecuredWritesCommand` any-of gate (arch-review UA1). The submit and approve/reject sub-actions are further gated by `RequireOperator` / `RequireVerifier` respectively (below).
- **Operator (submit)** — a submit form gated by `RequireOperator`: pick the site → an **MxGateway** connection on that site → the tag path → a typed value → an optional comment. Submission inserts a `Pending` `PendingSecuredWrite` row centrally; it does **not** write anything yet.
- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing. The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason.
- **History** — terminal rows (Executed / Failed / Rejected / Expired) with the full who/when/outcome trail (operator, verifier, comments, timestamps, any execution error).
- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing, and surfaces the **submission age** (`Submitted <age> ago (<UTC timestamp>)`) so a verifier can catch a stale setpoint before it reaches the device. The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason. The pending queue is **unpaged** — it is bounded by the submission **TTL**: a `Pending` row that is neither approved nor rejected before the TTL elapses expires to the terminal **`Expired`** status and drops out of the queue into History.
- **History** — terminal rows (Executed / Failed / Rejected / **Expired**, where `Expired` is a real TTL-driven terminal status) with the full who/when/outcome trail (operator, verifier, comments, timestamps, any execution error). Terminal rows accumulate without bound, so the History table is **offset-paged** (page-size 50, Prev/Next) driven by the server's unpaged `TotalCount` (`ListSecuredWritesCommand` `Skip`/`Take`); the pager buttons disable at the real bounds.
- Every lifecycle event (submit / approve / reject / execute) is written to the central Audit Log; the rows share the `PendingSecuredWrite.Id` as `CorrelationId` so they join into one operation (see Component-ManagementService.md, Component-AuditLog.md).
- **Dev caveat**: with `DisableLogin` on, the auto-login identity holds all roles, so the two-person flow cannot be exercised end-to-end by a single user via the dev UI — no-self-approval is covered by handler tests; real two-person use requires two real identities.
@@ -87,7 +87,7 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
### Templates
- **ListTemplates** / **GetTemplate**: Query template definitions.
- **ListTemplates** / **GetTemplate**: Query template definitions. `ListTemplates` supports additive offset paging via `Skip` / `Take` (arch-review P2): `Take = null` (the default) preserves the historical unpaged behaviour (unlimited), a set `Take` is clamped to `1..1000` and `Skip` floors at `0`.
- **CreateTemplate** / **UpdateTemplate** / **DeleteTemplate**: Manage templates.
- **ValidateTemplate**: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation).
- **GetTemplateDiff**: Compare deployed vs. template-derived configuration for an instance.
@@ -112,7 +112,7 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
### Instances
- **ListInstances** / **GetInstance**: Query instances, with filtering by site and area.
- **ListInstances** / **GetInstance**: Query instances, with filtering by site and area. `ListInstances` supports additive offset paging via `Skip` / `Take` (arch-review P2), applied **after** the in-memory site-scope filter so a site-scoped user never sees an out-of-scope instance surface into their page window; same `Take = null` unlimited default and `1..1000` clamp as `ListTemplates`. (`QueryDeployments` and `ExportBundle` still load the full table internally — a logged scale follow-up, arch-review P2 deferred.)
- **CreateInstance**: Create a new instance from a template.
- **UpdateInstanceOverrides**: Set attribute overrides on an instance.
- **SetInstanceAlarmOverride** / **DeleteInstanceAlarmOverride** / **ListInstanceAlarmOverrides**: Manage per-instance computed-alarm overrides.
@@ -134,10 +134,12 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
- **CreateDataConnection** / **UpdateDataConnection** / **DeleteDataConnection**: Manage data connection definitions.
- **AssignDataConnectionToSite** / **UnassignDataConnectionFromSite**: Manage site assignments.
> **Secret elision (arch-review C3).** The `PrimaryConfiguration`/`BackupConfiguration` JSON blobs can embed OPC UA user and certificate passwords, yet List/Get are readable by any authenticated user. All command responses **and** audit `afterState` are projected through a secret-elided shape (`DataConnectionPublicShape`): secret-bearing values inside the config JSON are replaced with a redaction sentinel and a `hasSecrets` flag is surfaced (mirrors the SMTP/SMS `HasCredentials` pattern). **Update** merges the sentinel via `ConfigSecretScrubber.MergeSentinels` — a client that round-trips a scrubbed config keeps the stored secret rather than wiping it.
### Deployments
- **DeployInstance**: Deploy configuration to a specific instance (includes pre-deployment validation).
- **DeployArtifacts**: Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites or a specific site.
- **DeployArtifacts**: Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites or a specific site. The command's `SiteId` is honored (arch-review C2): a value routes to the single-site deploy path (`ArtifactDeploymentService.DeployToSiteAsync`), while `null` deploys fleet-wide (`DeployToAllSitesAsync`). Site scope is enforced — a site-scoped (non-Administrator) Deployer may only target a site within its `PermittedSiteIds`, and may **not** deploy fleet-wide (`SiteId is null` is rejected with `SiteScopeViolationException` → `ManagementUnauthorized`, since `EnforceSiteScope(null)` is a deliberate no-op); fleet-wide deployment requires a system-wide Deployer or Administrator.
- **GetDeploymentStatus**: Query deployment status.
### Secured Writes (MxGateway, two-person)
@@ -147,13 +149,17 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
- **SubmitSecuredWriteCommand** (`Operator` role): validates the target connection exists and uses the **MxGateway** protocol (rejected otherwise), then inserts a `Pending` row capturing site / connection / tag / typed value / operator + comment. Nothing is written yet.
- **ApproveSecuredWriteCommand** (`Verifier` role): enforces **no self-approval** (`VerifierUser ≠ OperatorUser`, checked **before** any state change) and a **compare-and-swap** race guard (`TryMarkApprovedAsync` — exactly one concurrent approver flips `Pending → Approved`; the loser is rejected). On winning, it validates the value type, decodes the value (guarded — a bad value fails the row deterministically rather than leaving it stuck `Approved`), and relays a `WriteTagRequest` to the site MxGateway via the Communication Layer, recording the outcome as `Executed` or `Failed`.
- **RejectSecuredWriteCommand** (`Verifier` role): marks a `Pending` row `Rejected` with the verifier's reason.
- **List / query secured writes**: pending queue + terminal history, global or per-site.
- **ListSecuredWritesCommand** (any of `Operator` / `Verifier` / `Administrator`): pending queue + terminal history, global or per-site, **offset-paged** via `Skip` / `Take` (arch-review P3; additive — the defaults `Skip=0` / `Take=200` preserve the historical unpaged wire behaviour). The handler clamps `Take` to `1..500` and `Skip` to `>= 0`, calls `QueryAsync(Status, SiteId, skip, take)` for the page and `CountAsync(Status, SiteId)` for the unpaged total, and returns a `SecuredWriteListResult { Items, TotalCount }` so a client can render a pager. Although read-only, the history table exposes process-sensitive tag values, so it is **not** open to any authenticated user — it is gated to the two Secured Write roles plus `Administrator` (any-of), aligning with the `RequireSecuredWriteAccess` Central UI policy (arch-review UA1).
**Server-side pending TTL (arch-review S2).** A `Pending` secured write is not valid indefinitely. Both `ApproveSecuredWriteCommand` and `RejectSecuredWriteCommand` call a shared `TryExpireIfStaleAsync` helper immediately after the `Pending`-status check: if the row's `SubmittedAtUtc` is older than the configured `SecuredWritePendingTtl` (`ManagementServiceOptions`, default **24 h**), the helper atomically transitions it `Pending → Expired` via `ISecuredWriteRepository.TryMarkExpiredAsync` (compare-and-swap — no verifier recorded, `VerifierUser` stays `NULL`), emits a best-effort `SecuredWriteExpire` audit row (system actor, `Discarded` status — the closest terminal `AuditStatus`), and the command fails with an "expired" error. A stale setpoint therefore can **never** be approved or executed days later, and no `WriteTagRequest` is relayed. A non-positive TTL disables expiry. `ListSecuredWritesCommand` additionally runs an **opportunistic sweep**: on every read it walks the returned page and calls the same helper for each `Pending` row, so overdue writes surface as `Expired` the next time anyone views the queue — no dedicated expiry-timer singleton is needed because the ManagementActor already runs on every central node and the CAS makes concurrent multi-node sweeps idempotent.
### External Systems
- **ListExternalSystems** / **GetExternalSystem**: Query external system definitions.
- **CreateExternalSystem** / **UpdateExternalSystem** / **DeleteExternalSystem**: Manage external system definitions.
> **Secret elision (arch-review C3).** The `AuthConfiguration` blob holds API keys / Basic credentials. All command responses **and** audit `afterState` are projected through `ExternalSystemPublicShape`, which drops `AuthConfiguration` entirely and surfaces only a `hasAuthConfiguration` presence flag. **Update** is preserve-if-null: an omitted (`null`) `AuthConfiguration` leaves the stored secret intact (mirrors the SMTP/SMS credential rule).
### Notifications
- **ListNotificationLists** / **GetNotificationList**: Query notification lists.
@@ -186,6 +192,8 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
- **ListDatabaseConnections** / **GetDatabaseConnection**: Query database connection definitions.
- **CreateDatabaseConnection** / **UpdateDatabaseConnection** / **DeleteDatabaseConnection**: Manage database connections.
> **Secret elision (arch-review C3).** The ADO.NET `ConnectionString` can embed the SQL password. List/Get responses are projected through `DatabaseConnectionPublicShape` (`{ id, name, hasConnectionString }`) — the connection string itself is never emitted. `UpdateDatabaseConnectionDefCommand.ConnectionString` is nullable and preserve-if-null: an omitted (`null`) value leaves the stored connection string intact, so a round-trip of the elided shape does not wipe the secret.
### Inbound API Methods
- **ListApiMethods** / **GetApiMethod**: Query inbound API method definitions.
@@ -201,20 +209,22 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
- **QuerySiteEventLog**: Query site event log entries from a remote site (routed via communication layer). Supports date range, keyword search, and pagination.
- **QueryParkedMessages**: Query parked (dead-letter) messages at a remote site (routed via communication layer). Supports pagination.
- **DebugSnapshot**: Request a one-shot snapshot of attribute values and alarm states for a running instance. Resolves the instance's site from the config DB and routes via the communication layer. Uses 30s `QueryTimeout`.
- **BrowseNodeCommand**: Browse an OPC UA / MxGateway connection's address space one level at a time; supports a `BrowseNext` continuation token (paging) and returns per-node type-info for OPC UA Variables. (`Design` role.)
- **SearchAddressSpaceCommand**: Bounded recursive address-space search (depth + result caps, case-insensitive substring) against an OPC UA connection. (`Design` role.)
- **VerifyEndpointCommand**: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. (`Design` role — runs inside the Admin-gated connection editor.)
- **TrustServerCertCommand** / **RemoveServerCertCommand** / **ListServerCertsCommand**: Manage the site's OPC UA trusted-peer PKI store; Trust/Remove broadcast to **both** site nodes (see Component-SiteRuntime.md). (`Admin` role.)
- **BrowseNodeCommand**: Browse an OPC UA / MxGateway connection's address space one level at a time; supports a `BrowseNext` continuation token (paging) and returns per-node type-info for OPC UA Variables. Actor-dispatched — the handler requires the command's `SiteIdentifier`, enforces site-scope, then relays via `CommunicationService.BrowseNodeAsync` (arch-review C4, CLI/UI parity). (`Design` role.)
- **SearchAddressSpaceCommand**: Bounded recursive address-space search (depth + result caps, case-insensitive substring) against an OPC UA connection. Actor-dispatched, same `SiteIdentifier`-required + site-scope pattern, relayed via `CommunicationService.SearchAddressSpaceAsync` (arch-review C4). (`Design` role.)
- **VerifyEndpointCommand**: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. Actor-dispatched, `SiteIdentifier`-required + site-scope, relayed via `CommunicationService.VerifyEndpointAsync` (arch-review C4). (`Design` role — runs inside the Admin-gated connection editor.)
- **TrustServerCertCommand** / **RemoveServerCertCommand** / **ListServerCertsCommand**: Manage the site's OPC UA trusted-peer PKI store; Trust/Remove broadcast to **both** site nodes (see Component-SiteRuntime.md). Actor-dispatched and `Admin`-gated at the actor (arch-review C4) — each handler requires the command's `SiteIdentifier`, enforces site-scope, then relays via `CommunicationService.TrustServerCertAsync` / `RemoveServerCertAsync` / `ListServerCertsAsync`; the site-side reconcile across both nodes is owned by the site `CertStoreActor`. (`Admin` role.)
## Authorization
Every incoming message carries the authenticated user's identity and roles. The ManagementActor enforces the same role-based authorization rules as the Central UI:
- **Admin** role required for: site management, area management, API key management, role mapping management, scope rule management, system configuration.
- **Admin** role required for: site management, API key management, role mapping management, scope rule management, system configuration.
- **Design** role required for: template authoring (including template member management: attributes, alarms, native alarm sources, scripts, compositions), shared scripts, external system definitions, database connection definitions, notification lists, inbound API method definitions.
- **Deployment** role required for: instance management (including instance alarm overrides and native alarm source overrides), deployments, debug view, debug snapshot, parked message queries, site event log queries. Site scoping is enforced for site-scoped Deployment users.
- **Any of Design / Deployment** required for: area management (`CreateAreaCommand` / `UpdateAreaCommand` / `DeleteAreaCommand`). Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies — enforced as an any-of gate (arch-review C6). Administrator is not implicitly included.
- **Operator** role required for: submitting a secured write (`SubmitSecuredWriteCommand`).
- **Verifier** role required for: approving / rejecting a secured write (`ApproveSecuredWriteCommand` / `RejectSecuredWriteCommand`). The no-self-approval rule (`Operator ≠ Verifier`) is enforced in the handler, independent of the role check.
- **Any of Operator / Verifier / Administrator** required for: listing / querying secured writes (`ListSecuredWritesCommand`). Read-only, but the history exposes process-sensitive tag values, so it is gated any-of rather than open to every authenticated user (arch-review UA1). Enforced as an any-of gate (`GetRequiredRoles` returns the permitted set; the caller must hold at least one).
- **Admin** role additionally required for: server-certificate trust/remove/list (`TrustServerCertCommand` / `RemoveServerCertCommand` / `ListServerCertsCommand`).
- **Read-only access** (any authenticated role): health summary, health site, site event log queries, parked message queries.
@@ -245,6 +255,8 @@ The ManagementActor receives the following services and repositories via DI (inj
| Section | Options Class | Contents |
|---------|--------------|----------|
| `ScadaBridge:ManagementService` | `ManagementServiceOptions` | `CommandTimeout` (`TimeSpan`, default 30 s) — Ask timeout the HTTP endpoint applies when forwarding to the `ManagementActor`. A non-positive configured value falls back to the 30 s default. |
| | | `LongRunningCommandTimeout` (`TimeSpan`, default 5 min) — Ask timeout applied to long-running commands (`ImportBundle`, `PreviewBundle`, `ExportBundle`, `MgmtDeployArtifacts`, `MgmtDeployInstance`); all other commands use `CommandTimeout`. A non-positive configured value falls back to the 5 min default. |
| | | `SecuredWritePendingTtl` (`TimeSpan`, default 24 h) — age after which a `Pending` secured write is transitioned to `Expired` and can no longer be approved/executed; enforced at approve/reject and swept opportunistically on list. A non-positive value disables expiry (arch-review S2). |
## Dependencies
+19 -2
View File
@@ -85,7 +85,6 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
- **Permissions**:
- Manage site definitions.
- Manage site-level data connections (define and assign to sites).
- Manage area definitions per site.
- Manage LDAP group-to-role mappings.
- Manage API keys (create, enable/disable, delete).
- System-level configuration.
@@ -100,12 +99,14 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
- Manage database connection definitions.
- Manage notification lists and SMTP configuration.
- Manage inbound API method definitions.
- Manage area definitions per site (any-of with Deployer — see note below).
- Run on-demand validation (template flattening, script compilation).
### Deployer
- **Scope**: System-wide or site-scoped.
- **Permissions**:
- Create and manage instances (overrides, connection bindings, area assignment).
- Manage area definitions per site (any-of with Designer — see note below).
- Disable, enable, and delete instances.
- Deploy configurations to instances.
- Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites.
@@ -116,6 +117,8 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
- View site event logs.
- **Site scoping**: A user with site-scoped Deployer role can only perform these actions for instances at their permitted sites.
> **Area management (arch-review C6)**: Managing area definitions per site is gated **any-of [Designer, Deployer]**, not Administrator. Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies; Administrator is not implicitly included in the actor gate.
### Viewer
- **Scope**: System-wide (always).
- **Permissions**:
@@ -135,7 +138,11 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
- Approve or reject a pending secured write from the Secured Writes page — the *approving* half of the two-person write workflow.
- **Purpose**: The approving counterpart to **Operator**. Separation of duties is enforced **server-side**: the ManagementActor rejects any approval where the approving user equals the submitting Operator (no self-approval), so the two roles must be held by distinct principals for a write to execute. (See Component-ManagementService.md and Component-CentralUI.md.)
> **Two-person secured-write workflow.** `Operator` and `Verifier` are deliberately separate global roles so a single principal cannot both initiate and approve a write through the MxAccess Gateway. Both are coarse global roles like the others; any site scoping is layered on at the LDAP-mapping level. Note the dev `DisableLogin` caveat: with `DisableLogin` on, the auto-login principal holds **all** roles, so the two-person flow cannot be exercised end-to-end by a single identity — no-self-approval is covered by handler tests and real two-person use requires two real identities.
> **Two-person secured-write workflow.** `Operator` and `Verifier` are deliberately separate global roles so a single principal cannot both initiate and approve a write through the MxAccess Gateway. Both are coarse global roles like the others; any site scoping is layered on at the LDAP-mapping level.
>
> **Deployment-configuration hazard — never grant one principal both roles.** The whole control collapses if the same identity (or LDAP group) maps to *both* `Operator` and `Verifier`: server-side no-self-approval blocks approving the *exact* write you submitted, but a dual-role principal can still trivially pair-approve with a second submission, so a single compromised or careless account regains unilateral write. Keep `SCADA-Operators` and `SCADA-Verifiers` group membership disjoint. The dev `DisableLogin` caveat is the extreme of this: with `DisableLogin` on, the auto-login principal holds **all** roles, so the two-person flow cannot be exercised end-to-end by a single identity — which is exactly why `DisableLogin=true` is refused outside Development (see the `DisableLoginGuard` note above). No-self-approval is covered by handler tests; real two-person use requires two real, distinct-role identities.
>
> **Server-side pending TTL (arch-review S2).** A submitted secured write is not valid indefinitely. The ManagementActor enforces a configurable **`SecuredWritePendingTtl`** (`ManagementServiceOptions`, default **24 h**): a `Pending` row older than the TTL is atomically transitioned to **`Expired`** (compare-and-swap, so multi-node sweeps are idempotent) and can never be approved — the stale setpoint is never relayed to the site. Expiry is enforced eagerly at approve/reject (an overdue row fails with an "expired" error instead of executing) and opportunistically swept when the Secured Writes list is queried. A non-positive TTL disables expiry. This closes the "approve a days-old setpoint against a since-changed process" window.
## Authorization Policies
@@ -170,6 +177,7 @@ Role checks are expressed as named ASP.NET Core authorization policies (in `Auth
- `SCADA-Verifiers` → Verifier role (approves secured writes)
- A user can be a member of multiple groups, granting multiple independent roles.
- Group mappings are stored in the configuration database and managed via the Central UI (Administrator role).
- **Stored role casing is canonicalized at the write path** (arch-review C5): the mapping's `Role` arrives as a free string over the CLI/Management API, so `ManagementActor.HandleCreateRoleMapping`/`HandleUpdateRoleMapping` canonicalize it to the exact `Roles.All` casing (e.g. `deployer` → `Deployer`) before persisting, and reject any value not in `Roles.All`. This closes a CLI-works/UI-403s split-brain: the ASP.NET `RequireClaim` policies compare the role claim ordinal case-sensitively while the ManagementActor gates compare case-insensitively, so a row stored with off-canonical casing would pass every actor gate but fail every UI policy. Canonicalizing at this single server-side write path guarantees the two comparisons always agree.
## Permission Enforcement
@@ -192,3 +200,12 @@ Role checks are expressed as named ASP.NET Core authorization policies (in `Auth
- **All central components**: Role checks are a cross-cutting concern applied at the API layer.
- **Management Service**: The ManagementActor enforces role-based authorization on every incoming command using the authenticated user identity carried in the message envelope. The CLI authenticates users via the same LDAP bind mechanism and passes the user's identity (username, roles, permitted sites) in every request message. The ManagementActor applies the same role and site-scoping rules as the Central UI — no separate authentication path exists on the server side.
- **Transport (#24)**: Provides the `RequireDesign` policy (export) and `RequireAdmin` policy (import) enforced at both the Razor page layer and inside the `ZB.MOM.WW.ScadaBridge.Transport` service entrypoints.
## Login throttling
To blunt online password-spray and brute-force against the directory, every LDAP-bind surface consults a shared in-memory throttle (`LoginThrottle`, a `ZB.MOM.WW.ScadaBridge.Security` singleton) before attempting a bind, and records the bind outcome afterwards.
- **Scope — every LDAP-bind surface**: the Central UI interactive login (`POST /auth/login`) and CLI token issue (`POST /auth/token`), plus the HTTP-Basic management/CLI surfaces fronted by `ManagementAuthenticator` — `POST /management`, the audit REST endpoints, and the debug-stream hub. No bind happens outside these paths, so throttling coverage is complete. The dev/test `DisableLogin` bypass sits **above** the throttle: a login-disabled deployment never binds, so it is never throttled.
- **Key and window**: counters are keyed per `{username}|{IP}` pair (lower-cased username; remote IP from the connection). A fixed window of `LoginFailureWindowMinutes` (default 5) opens on the first failure; reaching `MaxLoginFailuresPerWindow` failures (default 5) within it locks that key out for `LoginLockoutMinutes` (default 5). A successful bind clears the key; an expired window resets the count. Setting `MaxLoginFailuresPerWindow` to `0` disables throttling entirely.
- **Behaviour when locked**: the surface refuses the bind without contacting LDAP. The Basic-Auth surfaces (`ManagementAuthenticator`) and `POST /auth/token` return HTTP `429` with `code = "AUTH_THROTTLED"`; `POST /auth/login` redirects to `/login` with a "Too many failed attempts. Try again later." message.
- **Best-effort, per-node**: the throttle is in-memory with no external state. The two central nodes maintain independent counters (a spray hitting both simply burns its budget on each). Memory is bounded — writes prune expired keys and cap the tracked-key count. This is a rate-limit guard, not an account-lockout policy; it never disables the directory account itself.
@@ -0,0 +1,221 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// CLI parity for the site-routed OPC UA data-connection operations that the
/// ManagementActor dispatches (arch-review C4): live address-space <c>browse</c> and
/// <c>search</c>, endpoint <c>verify-endpoint</c> probing, and server-certificate trust
/// management (<c>certs list|trust|remove</c>).
/// </summary>
/// <remarks>
/// These subcommands attach under the existing <c>data-connection</c> group. Every
/// command carries an explicit <c>--site</c> (mapped to the command record's
/// <c>SiteIdentifier</c>) so the management API can route it to the target site cluster
/// — the site side ignores it once routing has happened. <c>browse</c>/<c>search</c> and
/// <c>verify-endpoint</c> are Designer operations; <c>certs</c> are Admin operations,
/// enforced server-side by the ManagementActor.
/// </remarks>
public static class ConnectionBrowseCommands
{
/// <summary>
/// Attaches the browse / search / verify-endpoint / certs subcommands to the
/// <c>data-connection</c> command group.
/// </summary>
/// <param name="dataConnection">The existing <c>data-connection</c> group to extend.</param>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
/// <param name="commandInterceptor">
/// Test seam: when supplied, the built command record is handed to this delegate
/// (which returns the process exit code) instead of being sent over HTTP. Lets parse
/// tests assert the exact record produced from a given command line without a live
/// management server. Null in production (Program.cs), where the real HTTP path runs.
/// </param>
public static void AddTo(
Command dataConnection,
Option<string> urlOption,
Option<string> formatOption,
Option<string> usernameOption,
Option<string> passwordOption,
Func<object, int>? commandInterceptor = null)
{
dataConnection.Add(BuildBrowse(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor));
dataConnection.Add(BuildSearch(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor));
dataConnection.Add(BuildVerifyEndpoint(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor));
dataConnection.Add(BuildCerts(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor));
}
/// <summary>
/// Sends <paramref name="command"/> to the management API, or — when a test
/// interceptor is present — hands it to the interceptor instead.
/// </summary>
private static Task<int> DispatchAsync(
ParseResult result,
Option<string> urlOption,
Option<string> formatOption,
Option<string> usernameOption,
Option<string> passwordOption,
object command,
Func<object, int>? commandInterceptor)
{
if (commandInterceptor is not null)
return Task.FromResult(commandInterceptor(command));
return CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, command);
}
private static Command BuildBrowse(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
{
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
var connectionOption = new Option<string>("--connection") { Description = "Data connection name", Required = true };
var nodeIdOption = new Option<string?>("--node-id") { Description = "Parent node id to browse (server root / ObjectsFolder if omitted)" };
var cmd = new Command("browse") { Description = "Browse the immediate children of an OPC UA node on a site data connection" };
cmd.Add(siteOption);
cmd.Add(connectionOption);
cmd.Add(nodeIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var site = result.GetValue(siteOption)!;
var connection = result.GetValue(connectionOption)!;
var nodeId = result.GetValue(nodeIdOption);
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new BrowseNodeCommand(connection, nodeId, null, site), interceptor);
});
return cmd;
}
private static Command BuildSearch(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
{
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
var connectionOption = new Option<string>("--connection") { Description = "Data connection name", Required = true };
var queryOption = new Option<string>("--query") { Description = "Case-insensitive substring matched on DisplayName and root-relative path", Required = true };
var maxDepthOption = new Option<int>("--max-depth") { Description = "Maximum levels below the root to descend", DefaultValueFactory = _ => 5 };
var maxResultsOption = new Option<int>("--max-results") { Description = "Maximum number of matches to return", DefaultValueFactory = _ => 100 };
var cmd = new Command("search") { Description = "Bounded recursive search of an OPC UA address space on a site data connection" };
cmd.Add(siteOption);
cmd.Add(connectionOption);
cmd.Add(queryOption);
cmd.Add(maxDepthOption);
cmd.Add(maxResultsOption);
cmd.SetAction(async (ParseResult result) =>
{
var site = result.GetValue(siteOption)!;
var connection = result.GetValue(connectionOption)!;
var query = result.GetValue(queryOption)!;
var maxDepth = result.GetValue(maxDepthOption);
var maxResults = result.GetValue(maxResultsOption);
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new SearchAddressSpaceCommand(connection, query, maxDepth, maxResults, site), interceptor);
});
return cmd;
}
private static Command BuildVerifyEndpoint(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
{
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
var protocolOption = new Option<string>("--protocol") { Description = "Protocol type (e.g. OpcUa)", Required = true };
var configFileOption = new Option<string>("--config-file") { Description = "Path to the endpoint configuration JSON file (read verbatim into ConfigJson)", Required = true };
// Correlation only — a verify probe runs against an endpoint config that may not
// yet correspond to a saved connection, so the name is optional.
var connectionOption = new Option<string?>("--connection") { Description = "Connection name for logging/correlation (optional)" };
var cmd = new Command("verify-endpoint") { Description = "Probe a data-connection endpoint config on a site without persisting it (read-only; never trusts the server cert)" };
cmd.Add(siteOption);
cmd.Add(protocolOption);
cmd.Add(configFileOption);
cmd.Add(connectionOption);
cmd.SetAction(async (ParseResult result) =>
{
var site = result.GetValue(siteOption)!;
var protocol = result.GetValue(protocolOption)!;
var configFile = result.GetValue(configFileOption)!;
var connection = result.GetValue(connectionOption);
var configJson = await File.ReadAllTextAsync(configFile);
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new VerifyEndpointCommand(connection ?? string.Empty, protocol, configJson, site), interceptor);
});
return cmd;
}
private static Command BuildCerts(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
{
var group = new Command("certs") { Description = "Manage trusted OPC UA server certificates for a site" };
group.Add(BuildCertsList(urlOption, formatOption, usernameOption, passwordOption, interceptor));
group.Add(BuildCertsTrust(urlOption, formatOption, usernameOption, passwordOption, interceptor));
group.Add(BuildCertsRemove(urlOption, formatOption, usernameOption, passwordOption, interceptor));
return group;
}
private static Command BuildCertsList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
{
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
// Contextual only — the trusted-peer PKI store is site-scoped (not per-connection),
// so the connection is not part of the ListServerCertsCommand payload. Accepted for
// symmetry with the connection-scoped UI navigation.
var connectionOption = new Option<string?>("--connection") { Description = "Data connection name (contextual; not part of the request)" };
var cmd = new Command("list") { Description = "List the certificates in a site's trusted-peer and rejected PKI stores" };
cmd.Add(siteOption);
cmd.Add(connectionOption);
cmd.SetAction(async (ParseResult result) =>
{
var site = result.GetValue(siteOption)!;
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new ListServerCertsCommand(site), interceptor);
});
return cmd;
}
private static Command BuildCertsTrust(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
{
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
var connectionOption = new Option<string>("--connection") { Description = "Data connection the certificate was captured from (correlation)", Required = true };
var derFileOption = new Option<string>("--der-file") { Description = "Path to the DER-encoded certificate file (base64-encoded into the request)", Required = true };
var thumbprintOption = new Option<string>("--thumbprint") { Description = "Certificate thumbprint (used as the store filename key)", Required = true };
var cmd = new Command("trust") { Description = "Trust an OPC UA server certificate on every node of a site" };
cmd.Add(siteOption);
cmd.Add(connectionOption);
cmd.Add(derFileOption);
cmd.Add(thumbprintOption);
cmd.SetAction(async (ParseResult result) =>
{
var site = result.GetValue(siteOption)!;
var connection = result.GetValue(connectionOption)!;
var derFile = result.GetValue(derFileOption)!;
var thumbprint = result.GetValue(thumbprintOption)!;
var derBase64 = Convert.ToBase64String(await File.ReadAllBytesAsync(derFile));
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new TrustServerCertCommand(connection, derBase64, thumbprint, site), interceptor);
});
return cmd;
}
private static Command BuildCertsRemove(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
{
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
var thumbprintOption = new Option<string>("--thumbprint") { Description = "Thumbprint of the certificate to remove", Required = true };
// Contextual only — RemoveServerCertCommand is keyed by thumbprint + site, not connection.
var connectionOption = new Option<string?>("--connection") { Description = "Data connection name (contextual; not part of the request)" };
var cmd = new Command("remove") { Description = "Remove a trusted OPC UA server certificate from every node of a site" };
cmd.Add(siteOption);
cmd.Add(thumbprintOption);
cmd.Add(connectionOption);
cmd.SetAction(async (ParseResult result) =>
{
var site = result.GetValue(siteOption)!;
var thumbprint = result.GetValue(thumbprintOption)!;
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new RemoveServerCertCommand(thumbprint, site), interceptor);
});
return cmd;
}
}
@@ -212,19 +212,25 @@ public static class InstanceCommands
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
var templateIdOption = new Option<int?>("--template-id") { Description = "Filter by template ID" };
var searchOption = new Option<string?>("--search") { Description = "Search term" };
var skipOption = new Option<int>("--skip") { Description = "Offset paging: number of items to skip (default 0)" };
var takeOption = new Option<int?>("--take") { Description = "Offset paging: max items to return (1..1000; omit for unlimited)" };
var cmd = new Command("list") { Description = "List instances" };
cmd.Add(siteIdOption);
cmd.Add(templateIdOption);
cmd.Add(searchOption);
cmd.Add(skipOption);
cmd.Add(takeOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
var templateId = result.GetValue(templateIdOption);
var search = result.GetValue(searchOption);
var skip = result.GetValue(skipOption);
var take = result.GetValue(takeOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new ListInstancesCommand(siteId, templateId, search));
new ListInstancesCommand(siteId, templateId, search, skip, take));
});
return cmd;
}
@@ -0,0 +1,71 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Builds the <c>secured-write</c> command group. Currently exposes the read-only
/// <c>list</c> sub-command, which relays a <see cref="ListSecuredWritesCommand"/> to the
/// central <c>ManagementActor</c> (gated any-of <c>Operator</c> / <c>Verifier</c> /
/// <c>Administrator</c>). The list is offset-paged via <c>--skip</c> / <c>--take</c>
/// (arch-review P3); omitting them preserves the historical unpaged default (first 200 rows).
/// </summary>
public static class SecuredWriteCommands
{
// Options are static so the parsed values can be read back from both the SetAction
// and the internal BuildListCommand helper (used by command-construction tests).
private static readonly Option<string?> StatusOption =
new("--status") { Description = "Filter by status (e.g. Pending, Approved, Rejected, Expired, Executed, Failed); omit to match all" };
private static readonly Option<string?> SiteOption =
new("--site") { Description = "Filter by site identifier; omit to match all sites" };
private static readonly Option<int> SkipOption =
new("--skip") { Description = "Number of rows to skip (offset paging)", DefaultValueFactory = _ => 0 };
private static readonly Option<int> TakeOption =
new("--take") { Description = "Maximum rows to return (clamped to 1..500 server-side)", DefaultValueFactory = _ => 200 };
/// <summary>Builds the <c>secured-write</c> command group with its <c>list</c> sub-command.</summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
/// <returns>The configured <c>secured-write</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("secured-write") { Description = "Query two-person (MxGateway) secured writes" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List secured writes (pending queue + terminal history), optionally filtered and paged" };
cmd.Add(StatusOption);
cmd.Add(SiteOption);
cmd.Add(SkipOption);
cmd.Add(TakeOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
BuildListCommand(result));
});
return cmd;
}
/// <summary>
/// Builds a <see cref="ListSecuredWritesCommand"/> from a parsed <c>secured-write list</c>
/// invocation. Exposed internally so command-construction tests can assert field mapping
/// (including the <c>--skip</c> / <c>--take</c> paging options) without triggering the HTTP call.
/// </summary>
/// <param name="result">The parsed command-line result from the <c>secured-write list</c> invocation.</param>
/// <returns>A <see cref="ListSecuredWritesCommand"/> populated from the parsed result.</returns>
internal static ListSecuredWritesCommand BuildListCommand(ParseResult result)
{
var status = result.GetValue(StatusOption);
var site = result.GetValue(SiteOption);
var skip = result.GetValue(SkipOption);
var take = result.GetValue(TakeOption);
return new ListSecuredWritesCommand(status, site, skip, take);
}
}
@@ -59,13 +59,19 @@ public static class TemplateCommands
Description = "Include full template definitions (all attributes/alarms/scripts) in table output. "
+ "Without it, table output is a compact summary (counts only). JSON output is always full."
};
var skipOption = new Option<int>("--skip") { Description = "Offset paging: number of items to skip (default 0)" };
var takeOption = new Option<int?>("--take") { Description = "Offset paging: max items to return (1..1000; omit for unlimited)" };
var cmd = new Command("list") { Description = "List all templates (compact table summary; use --detail for the full dump)" };
cmd.Add(detailOption);
cmd.Add(skipOption);
cmd.Add(takeOption);
cmd.SetAction(async (ParseResult result) =>
{
var detail = result.GetValue(detailOption);
var skip = result.GetValue(skipOption);
var take = result.GetValue(takeOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand(),
result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand(skip, take),
tableProjector: detail ? null : TemplateTableProjection.ProjectSummary);
});
return cmd;
+5 -1
View File
@@ -22,7 +22,10 @@ rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption,
rootCommand.Add(InstanceCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SiteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DeployCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DataConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
var dataConnectionCommand = DataConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption);
// Attach the site-routed browse/search/verify-endpoint/certs subcommands (actor parity, arch-review C4).
ConnectionBrowseCommands.AddTo(dataConnectionCommand, urlOption, formatOption, usernameOption, passwordOption);
rootCommand.Add(dataConnectionCommand);
rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
@@ -35,6 +38,7 @@ rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOpti
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
+94 -3
View File
@@ -101,6 +101,8 @@ scadabridge --url <url> --format json template list # full JSON (always)
| Option | Required | Description |
|--------|----------|-------------|
| `--detail` | no | Include full definitions in table output (no effect on JSON) |
| `--skip` | no | Offset paging: number of items to skip (default 0) |
| `--take` | no | Offset paging: max items to return (clamped 1..1000; omit for unlimited) |
#### `template get`
@@ -450,7 +452,7 @@ scadabridge --url <url> instance get --id <int>
List instances, with optional filters.
```sh
scadabridge --url <url> instance list [--site-id <int>] [--template-id <int>] [--search <string>]
scadabridge --url <url> instance list [--site-id <int>] [--template-id <int>] [--search <string>] [--skip <int>] [--take <int>]
```
| Option | Required | Description |
@@ -458,6 +460,8 @@ scadabridge --url <url> instance list [--site-id <int>] [--template-id <int>] [-
| `--site-id` | no | Filter by site ID |
| `--template-id` | no | Filter by template ID |
| `--search` | no | Search term matched against instance name |
| `--skip` | no | Offset paging: number of items to skip (default 0). Applied after site-scope filtering. |
| `--take` | no | Offset paging: max items to return (clamped 1..1000; omit for unlimited) |
#### `instance create`
@@ -760,7 +764,7 @@ scadabridge --url <url> site deploy-artifacts [--site-id <int>]
| Option | Required | Description |
|--------|----------|-------------|
| `--site-id` | no | Target site ID; omit to deploy to all sites |
| `--site-id` | no | Target site ID; deploys system-wide artifacts to exactly that site. Omit to deploy fleet-wide to all sites. A site-scoped (non-Administrator) Deployer must supply an in-scope `--site-id`; fleet-wide deployment requires a system-wide Deployer or Administrator (arch-review C2). |
#### `site area list`
@@ -840,7 +844,7 @@ scadabridge --url <url> deploy artifacts [--site-id <int>]
| Option | Required | Description |
|--------|----------|-------------|
| `--site-id` | no | Target site ID; omit for all sites |
| `--site-id` | no | Target site ID; deploys to exactly that site. Omit to deploy fleet-wide. A site-scoped (non-Administrator) Deployer must supply an in-scope `--site-id`; fleet-wide requires a system-wide Deployer or Administrator (arch-review C2). |
#### `deploy status`
@@ -932,6 +936,93 @@ scadabridge --url <url> data-connection delete --id <int>
|--------|----------|-------------|
| `--id` | yes | Data connection ID |
#### `data-connection browse`
Browse the immediate children of an OPC UA node on the live server backing a site data connection. `--site` routes the command to the target site. Designer role.
```sh
scadabridge --url <url> data-connection browse --site <identifier> --connection <name> [--node-id <nodeId>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site` | yes | Target site identifier |
| `--connection` | yes | Data connection name |
| `--node-id` | no | Parent node id to browse (server root / ObjectsFolder if omitted) |
#### `data-connection search`
Bounded recursive search of an OPC UA address space (substring on DisplayName / root-relative path). Designer role.
```sh
scadabridge --url <url> data-connection search --site <identifier> --connection <name> --query <text> [--max-depth <int>] [--max-results <int>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site` | yes | Target site identifier |
| `--connection` | yes | Data connection name |
| `--query` | yes | Case-insensitive substring matched on DisplayName and root-relative path |
| `--max-depth` | no | Maximum levels below the root to descend (default 5) |
| `--max-results` | no | Maximum number of matches to return (default 100) |
#### `data-connection verify-endpoint`
Probe an endpoint configuration on a site without persisting it (read-only; never trusts the server certificate). The endpoint JSON is read verbatim from `--config-file`. Designer role.
```sh
scadabridge --url <url> data-connection verify-endpoint --site <identifier> --protocol <string> --config-file <path> [--connection <name>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site` | yes | Target site identifier |
| `--protocol` | yes | Protocol type (e.g. `OpcUa`) |
| `--config-file` | yes | Path to the endpoint configuration JSON file (read into `ConfigJson`) |
| `--connection` | no | Connection name for logging/correlation |
#### `data-connection certs list`
List the certificates in a site's trusted-peer and rejected PKI stores. Admin role.
```sh
scadabridge --url <url> data-connection certs list --site <identifier> [--connection <name>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site` | yes | Target site identifier |
| `--connection` | no | Data connection name (contextual; not part of the request) |
#### `data-connection certs trust`
Trust an OPC UA server certificate on every node of a site. The DER file is base64-encoded into the request. Admin role.
```sh
scadabridge --url <url> data-connection certs trust --site <identifier> --connection <name> --der-file <path> --thumbprint <hex>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site` | yes | Target site identifier |
| `--connection` | yes | Data connection the certificate was captured from (correlation) |
| `--der-file` | yes | Path to the DER-encoded certificate file (base64-encoded into the request) |
| `--thumbprint` | yes | Certificate thumbprint (store filename key) |
#### `data-connection certs remove`
Remove a trusted OPC UA server certificate from every node of a site, identified by thumbprint. Admin role.
```sh
scadabridge --url <url> data-connection certs remove --site <identifier> --thumbprint <hex> [--connection <name>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site` | yes | Target site identifier |
| `--thumbprint` | yes | Thumbprint of the certificate to remove |
| `--connection` | no | Data connection name (contextual; not part of the request) |
---
### `external-system` — Manage external HTTP systems
@@ -33,17 +33,30 @@ public static class AuthEndpoints
return;
}
// Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5).
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
context.Response.Redirect("/login?error=Too+many+failed+attempts.+Try+again+later.");
return;
}
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure));
context.Response.Redirect($"/login?error={errorMsg}");
return;
}
throttle.RecordSuccess(username, remoteIp!);
// Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
// (ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
// The full RoleMappingResult — including PermittedSiteIds and the
@@ -107,6 +120,17 @@ public static class AuthEndpoints
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
// Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5).
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
return Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429);
}
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
@@ -114,11 +138,14 @@ public static class AuthEndpoints
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
return Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
statusCode: 401);
}
throttle.RecordSuccess(username, remoteIp!);
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
// Guard the opaque-Scope unwrap (review I4); see the matching note on
@@ -109,7 +109,7 @@
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="as-f-instance">Instance</label>
<select id="as-f-instance" class="form-select form-select-sm" style="min-width: 160px;" @bind="_filterInstance">
<select id="as-f-instance" class="form-select form-select-sm" style="min-width: 160px;" @bind="_filterInstance" @bind:after="RecomputeVisibleRows">
<option value="">All</option>
@foreach (var name in DistinctInstances)
{
@@ -119,7 +119,7 @@
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="as-f-kind">Kind</label>
<select id="as-f-kind" class="form-select form-select-sm" @bind="_filterKind">
<select id="as-f-kind" class="form-select form-select-sm" @bind="_filterKind" @bind:after="RecomputeVisibleRows">
<option value="">All</option>
<option value="@AlarmKind.Computed">Computed</option>
<option value="@AlarmKind.NativeOpcUa">OPC UA</option>
@@ -128,7 +128,7 @@
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="as-f-state">State</label>
<select id="as-f-state" class="form-select form-select-sm" @bind="_filterState">
<select id="as-f-state" class="form-select form-select-sm" @bind="_filterState" @bind:after="RecomputeVisibleRows">
<option value="">All</option>
<option value="@AlarmState.Active">Active</option>
<option value="@AlarmState.Normal">Normal</option>
@@ -136,7 +136,7 @@
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="as-f-ack">Ack</label>
<select id="as-f-ack" class="form-select form-select-sm" @bind="_filterAck">
<select id="as-f-ack" class="form-select form-select-sm" @bind="_filterAck" @bind:after="RecomputeVisibleRows">
<option value="">Any</option>
<option value="unacked">Unacked</option>
<option value="acked">Acked</option>
@@ -146,12 +146,12 @@
<label class="form-label small mb-1" for="as-f-sev">Min severity</label>
<input id="as-f-sev" type="number" min="0" max="1000" step="50"
class="form-control form-control-sm" style="width: 110px;"
@bind="_filterMinSeverity" />
@bind="_filterMinSeverity" @bind:after="RecomputeVisibleRows" />
</div>
<div class="col-auto flex-grow-1">
<label class="form-label small mb-1" for="as-f-name">Name search</label>
<input id="as-f-name" type="text" class="form-control form-control-sm"
placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" />
placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" @bind:after="RecomputeVisibleRows" />
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters">Clear</button>
@@ -167,20 +167,30 @@
}
else
{
var filtered = FilteredRows().ToList();
<div class="text-muted small mb-2">Showing @filtered.Count of @_rows.Count</div>
<div class="text-muted small mb-2">Showing @_visibleRows.Count of @_rows.Count</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
@* UA6 (arch-review): sortable headers are keyboard-operable — tabindex="0",
aria-sort reflects the active sort direction, and Enter/Space toggle the
sort (Space preventDefault suppresses page scroll). The same pattern should
be applied to the other custom grids in the app; that fleet-wide sweep is
deferred and logged (arch-review UA6). *@
<thead>
<tr>
<th role="button" @onclick='() => SortBy("instance")'>Instance @SortGlyph("instance")</th>
<th role="button" @onclick='() => SortBy("name")'>Alarm @SortGlyph("name")</th>
<th role="button" tabindex="0" aria-sort="@AriaSortFor("instance")"
@onclick='() => SortBy("instance")'
@onkeydown='e => OnHeaderKeyDown(e, "instance")' @onkeydown:preventDefault="_preventHeaderDefault">Instance @SortGlyph("instance")</th>
<th role="button" tabindex="0" aria-sort="@AriaSortFor("name")"
@onclick='() => SortBy("name")'
@onkeydown='e => OnHeaderKeyDown(e, "name")' @onkeydown:preventDefault="_preventHeaderDefault">Alarm @SortGlyph("name")</th>
<th>State / Kind</th>
<th role="button" class="text-end" @onclick='() => SortBy("severity")'>Severity @SortGlyph("severity")</th>
<th role="button" tabindex="0" class="text-end" aria-sort="@AriaSortFor("severity")"
@onclick='() => SortBy("severity")'
@onkeydown='e => OnHeaderKeyDown(e, "severity")' @onkeydown:preventDefault="_preventHeaderDefault">Severity @SortGlyph("severity")</th>
</tr>
</thead>
<tbody>
@foreach (var row in filtered)
@foreach (var row in _visibleRows)
{
<tr data-test="alarm-summary-row">
<td class="font-monospace small">@row.InstanceUniqueName</td>
@@ -201,11 +211,18 @@
private int? _selectedSiteId;
private IReadOnlyList<AlarmSummaryRow> _rows = Array.Empty<AlarmSummaryRow>();
// P4 (arch-review): the filtered+sorted view is memoized rather than recomputed
// on every render. RecomputeVisibleRows() refreshes it whenever the inputs change
// (a fresh snapshot in RefreshAsync, a filter change via @bind:after, or a sort).
private IReadOnlyList<AlarmSummaryRow> _visibleRows = Array.Empty<AlarmSummaryRow>();
private IReadOnlyList<string> _notReporting = Array.Empty<string>();
private AlarmRollup _rollup = new(0, 0, 0, new Dictionary<AlarmKind, int>());
private bool _loading;
private Timer? _refreshTimer;
private readonly ZB.MOM.WW.ScadaBridge.CentralUI.Services.PollGate _pollGate = new();
private const int _autoRefreshSeconds = 15;
// ── Client-side filters ──
@@ -265,6 +282,7 @@
_rows = result.Alarms;
_notReporting = result.NotReportingInstances;
_rollup = AlarmSummaryService.ComputeRollup(_rows);
RecomputeVisibleRows();
}
catch
{
@@ -282,10 +300,18 @@
StopTimer();
_refreshTimer = new Timer(_ =>
{
if (!_pollGate.TryEnter()) return;
InvokeAsync(async () =>
{
await RefreshAsync();
StateHasChanged();
try
{
await RefreshAsync();
StateHasChanged();
}
finally
{
_pollGate.Exit();
}
});
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
@@ -299,6 +325,11 @@
private IEnumerable<string> DistinctInstances =>
_rows.Select(r => r.InstanceUniqueName).Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase);
// P4: recompute the memoized filtered+sorted view from the current snapshot and
// filter/sort inputs. Called from RefreshAsync, each filter's @bind:after, and SortBy —
// NOT per render.
private void RecomputeVisibleRows() => _visibleRows = FilteredRows().ToList();
private IEnumerable<AlarmSummaryRow> FilteredRows()
{
IEnumerable<AlarmSummaryRow> q = _rows;
@@ -357,11 +388,34 @@
_sortKey = key;
_sortDescending = key == "severity";
}
RecomputeVisibleRows();
}
private string SortGlyph(string key) =>
_sortKey != key ? "" : (_sortDescending ? "▼" : "▲");
// UA6: aria-sort token for a sortable header — ascending/descending on the active
// column, "none" otherwise (per the WAI-ARIA aria-sort value set).
private string AriaSortFor(string key) =>
_sortKey != key ? "none" : (_sortDescending ? "descending" : "ascending");
// UA6: whether the last header keydown should suppress the browser default. Bound to
// @onkeydown:preventDefault (evaluated at render), so it takes effect on the next
// Space keydown to stop the page scrolling when a header has keyboard focus.
private bool _preventHeaderDefault;
// UA6: keyboard activation for the sortable headers — Enter or Space toggles the sort,
// mirroring the pointer click.
private void OnHeaderKeyDown(KeyboardEventArgs e, string key)
{
var isSpace = e.Key is " " or "Spacebar";
_preventHeaderDefault = isSpace; // suppress Space page-scroll; leave Tab/others alone
if (e.Key == "Enter" || isSpace)
{
SortBy(key);
}
}
private void ClearFilters()
{
_filterInstance = "";
@@ -370,6 +424,7 @@
_filterAck = "";
_filterMinSeverity = null;
_filterName = "";
RecomputeVisibleRows();
}
private static string KindLabel(AlarmKind kind) => kind switch
@@ -444,6 +444,7 @@
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
private Dictionary<string, string> _siteNames = new();
private Timer? _refreshTimer;
private readonly ZB.MOM.WW.ScadaBridge.CentralUI.Services.PollGate _pollGate = new();
private int _autoRefreshSeconds = 10;
// Notification Outbox headline KPIs, refreshed alongside the site states.
@@ -542,10 +543,18 @@
_refreshTimer = new Timer(_ =>
{
if (!_pollGate.TryEnter()) return;
InvokeAsync(async () =>
{
await RefreshNow();
StateHasChanged();
try
{
await RefreshNow();
StateHasChanged();
}
finally
{
_pollGate.Exit();
}
});
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
@@ -1,5 +1,8 @@
@page "/operations/secured-writes"
@attribute [Authorize]
@* arch-review UA1: the Secured Writes history exposes process-sensitive tag
values, so the page is gated to the Secured Write roles + Administrator
(any-of), aligning with the ManagementActor ListSecuredWrites gate. *@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireSecuredWriteAccess)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@@ -218,6 +221,22 @@
</tbody>
</table>
}
@if (_historyTotalCount > HistoryPageSize)
{
<div class="d-flex justify-content-between align-items-center p-2 border-top small"
data-test="secured-write-history-pager">
<span class="text-muted">
Showing @(_historyPage * HistoryPageSize + 1)–@Math.Min((_historyPage + 1) * HistoryPageSize, _historyTotalCount)
of @_historyTotalCount
</span>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" @onclick="HistoryPrev"
disabled="@(_historyPage == 0)" data-test="secured-write-history-prev">Prev</button>
<button class="btn btn-outline-secondary" @onclick="HistoryNext"
disabled="@(!HasNextHistoryPage)" data-test="secured-write-history-next">Next</button>
</div>
</div>
}
</div>
</div>
</div>
@@ -231,6 +250,18 @@
private List<SecuredWriteDto> _history = new();
private string _currentUsername = ClaimsPrincipalExtensions.UnknownUser;
// History pager. Terminal rows can accumulate without bound, so the History table is
// offset-paged (page-size 50); the Pending queue stays unpaged because it is now
// bounded by the submission TTL (arch-review S2). _historyTotalCount is the server's
// unpaged match count, so Prev/Next disable at the real bounds.
private const int HistoryPageSize = 50;
// Pending is bounded by the TTL; fetch a generous cap in one unpaged read.
private const int PendingFetchLimit = 500;
private int _historyPage; // 0-based
private int _historyTotalCount;
private bool HasNextHistoryPage => (_historyPage + 1) * HistoryPageSize < _historyTotalCount;
// Submit-form state.
private string _formSiteIdentifier = "";
private string _formConnectionName = "";
@@ -302,9 +333,46 @@
private async Task RefreshListsAsync()
{
var all = await SecuredWriteSvc.ListAsync(status: null, siteId: null);
_pending = all.Where(w => string.Equals(w.Status, "Pending", StringComparison.OrdinalIgnoreCase)).ToList();
_history = all.Where(w => TerminalStatuses.Contains(w.Status, StringComparer.OrdinalIgnoreCase)).ToList();
// Pending queue: unpaged (bounded by the submission TTL), filtered to Pending.
var pending = await SecuredWriteSvc.ListAsync(
status: "Pending", siteId: null, skip: 0, take: PendingFetchLimit);
_pending = pending.Items
.Where(w => string.Equals(w.Status, "Pending", StringComparison.OrdinalIgnoreCase))
.ToList();
// History: terminal rows, offset-paged. TotalCount drives the pager bounds.
var history = await SecuredWriteSvc.ListAsync(
status: null, siteId: null, skip: _historyPage * HistoryPageSize, take: HistoryPageSize);
_history = history.Items
.Where(w => TerminalStatuses.Contains(w.Status, StringComparer.OrdinalIgnoreCase))
.ToList();
_historyTotalCount = history.TotalCount;
}
private async Task HistoryNext()
{
if (!HasNextHistoryPage) return;
_historyPage++;
await RefreshListsAsync();
}
private async Task HistoryPrev()
{
if (_historyPage == 0) return;
_historyPage--;
await RefreshListsAsync();
}
/// <summary>Renders a coarse, human-readable age for a submission timestamp.</summary>
private static string FormatAge(DateTime submittedUtc)
{
var age = DateTime.UtcNow - submittedUtc;
if (age < TimeSpan.Zero) age = TimeSpan.Zero;
if (age.TotalMinutes < 1) return "less than a minute";
if (age.TotalHours < 1) { var m = (int)age.TotalMinutes; return $"{m} minute{(m == 1 ? "" : "s")}"; }
if (age.TotalDays < 1) { var h = (int)age.TotalHours; return $"{h} hour{(h == 1 ? "" : "s")}"; }
var d = (int)age.TotalDays;
return $"{d} day{(d == 1 ? "" : "s")}";
}
private async Task Submit()
@@ -343,7 +411,9 @@
"Approve secured write",
$"Approving will write to the device.\n\n" +
$"Site: {row.SiteId}\nConnection: {row.ConnectionName}\nTag: {row.TagPath}\n" +
$"Value: {row.ValueJson} ({row.ValueType})\nOperator: {row.OperatorUser}",
$"Value: {row.ValueJson} ({row.ValueType})\n" +
$"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" +
$"Operator: {row.OperatorUser}",
danger: true);
if (!confirmed) return;
@@ -92,13 +92,18 @@ public interface ISecuredWriteService
/// <summary>
/// Lists secured writes, optionally filtered by <paramref name="status"/> and
/// <paramref name="siteId"/> (read-only — any authenticated user). On any failure
/// an empty list is returned so the page renders gracefully.
/// <paramref name="siteId"/> with offset paging (read-only — any authenticated user).
/// On any failure an empty result (<c>TotalCount = 0</c>) is returned so the page
/// renders gracefully. The returned <see cref="SecuredWriteListResult.TotalCount"/> is
/// the unpaged match count, so the page can drive a pager independent of page size.
/// </summary>
/// <param name="status">Status filter; <c>null</c> matches every status.</param>
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
/// <param name="skip">Rows to skip (offset paging); clamped to &gt;= 0 server-side.</param>
/// <param name="take">Maximum rows to return; clamped to 1..500 server-side.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the matching rows, newest submission first.</returns>
Task<IReadOnlyList<SecuredWriteDto>> ListAsync(
string? status, string? siteId, CancellationToken cancellationToken = default);
/// <returns>A task resolving to the matching page + total match count, newest submission first.</returns>
Task<SecuredWriteListResult> ListAsync(
string? status, string? siteId, int skip = 0, int take = 200,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>Reentrancy guard for poll timers: a fixed-period Timer whose refresh outlives
/// the period stacks overlapping fan-outs against an already-degraded site (arch-review S3).
/// Callers TryEnter at tick start and skip the tick when the previous refresh is in flight.</summary>
public sealed class PollGate
{
private int _inFlight;
public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
public void Exit() => Volatile.Write(ref _inFlight, 0);
}
@@ -79,21 +79,23 @@ public sealed class SecuredWriteService : ISecuredWriteService
=> DispatchAsync(new RejectSecuredWriteCommand(id, comment), cancellationToken);
/// <inheritdoc/>
public async Task<IReadOnlyList<SecuredWriteDto>> ListAsync(
string? status, string? siteId, CancellationToken cancellationToken = default)
public async Task<SecuredWriteListResult> ListAsync(
string? status, string? siteId, int skip = 0, int take = 200,
CancellationToken cancellationToken = default)
{
var response = await SendAsync(new ListSecuredWritesCommand(status, siteId), cancellationToken);
var response = await SendAsync(
new ListSecuredWritesCommand(status, siteId, skip, take), cancellationToken);
if (response is ManagementSuccess success)
{
var result = JsonSerializer.Deserialize<SecuredWriteListResult>(
success.JsonData, ResultDeserializerOptions);
return result?.Items ?? Array.Empty<SecuredWriteDto>();
return result ?? new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0);
}
// Read path: log + return empty so the queue/history tables render gracefully.
_logger.LogWarning(
"ListSecuredWrites failed: {Response}", DescribeFailure(response));
return Array.Empty<SecuredWriteDto>();
return new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0);
}
/// <summary>
@@ -79,4 +79,34 @@ public interface ISecuredWriteRepository
string? verifierComment,
DateTime decidedAtUtc,
CancellationToken ct = default);
/// <summary>
/// Atomically flips a row from <c>Pending</c> to <c>Expired</c>, stamping the
/// expiry as the decision time, but ONLY if the row is still <c>Pending</c>.
/// This is the multi-node-safe expiry primitive: the conditional
/// <c>WHERE Status='Pending'</c> makes the Pending-&gt;Expired transition atomic at
/// the row level, so two nodes sweeping the same overdue write concurrently
/// produce exactly one winner. The expiry is a system decision — no verifier is
/// recorded and <see cref="PendingSecuredWrite.VerifierUser"/> stays <c>null</c>.
/// The loser observes <c>false</c> (the row was already decided or expired).
/// </summary>
/// <param name="id">Identity of the pending secured write.</param>
/// <param name="expiredAtUtc">UTC instant the write was deemed expired.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// A task resolving to <c>true</c> if this caller won the expiry (exactly one
/// row transitioned), or <c>false</c> if the row was no longer <c>Pending</c>.
/// </returns>
Task<bool> TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default);
/// <summary>
/// Returns the number of rows matching the optional <paramref name="status"/> and
/// <paramref name="siteId"/> filters. A <c>null</c> filter argument matches every
/// row. Mirrors <see cref="QueryAsync"/>'s filter composition without paging.
/// </summary>
/// <param name="status">Status filter; <c>null</c> matches every status.</param>
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to the count of matching rows.</returns>
Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default);
}
@@ -21,10 +21,16 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// request carry back the token from the prior <see cref="BrowseNodeResult.ContinuationToken"/>.
/// Additive (appended last) so positional construction stays source-compatible.
/// </param>
/// <param name="SiteIdentifier">
/// Target site identifier — REQUIRED when the command is invoked via the management
/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where
/// routing already happened. Additive per the message-contract evolution rules.
/// </param>
public record BrowseNodeCommand(
string ConnectionName,
string? ParentNodeId,
string? ContinuationToken = null);
string? ContinuationToken = null,
string? SiteIdentifier = null);
/// <param name="Children">Immediate children resolved for the browsed node (this page only).</param>
/// <param name="Truncated">True when the result was clipped (frame-budget cap or adapter-reported truncation).</param>
@@ -59,11 +65,17 @@ public record BrowseNodeResult(
/// <param name="Query">Case-insensitive substring matched against each node's DisplayName and root-relative path.</param>
/// <param name="MaxDepth">Maximum number of levels below the root to descend. Must be non-negative.</param>
/// <param name="MaxResults">Maximum number of matches to return; when reached the walk stops early and <see cref="SearchAddressSpaceResult.CapReached"/> is set.</param>
/// <param name="SiteIdentifier">
/// Target site identifier — REQUIRED when the command is invoked via the management
/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where
/// routing already happened. Additive per the message-contract evolution rules.
/// </param>
public record SearchAddressSpaceCommand(
string ConnectionName,
string Query,
int MaxDepth,
int MaxResults);
int MaxResults,
string? SiteIdentifier = null);
/// <param name="Matches">The matched address-space nodes, in breadth-first discovery order (capped at <see cref="SearchAddressSpaceCommand.MaxResults"/>).</param>
/// <param name="CapReached">True when a bound (result cap or the adapter's node-visit ceiling) cut the walk short, so more matches may exist than were returned.</param>
@@ -22,21 +22,36 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// <param name="ConnectionName">The data-connection the certificate was captured from (diagnostics / correlation only).</param>
/// <param name="DerBase64">The server certificate's DER encoding, base64-encoded.</param>
/// <param name="Thumbprint">The certificate thumbprint — used as the store filename key.</param>
public record TrustServerCertCommand(string ConnectionName, string DerBase64, string Thumbprint);
/// <param name="SiteIdentifier">
/// Target site identifier — REQUIRED when the command is invoked via the management
/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where
/// routing already happened. Additive per the message-contract evolution rules.
/// </param>
public record TrustServerCertCommand(string ConnectionName, string DerBase64, string Thumbprint, string? SiteIdentifier = null);
/// <summary>
/// Remove a previously-trusted OPC UA server certificate from every site
/// node's trusted-peer PKI store, identified by thumbprint.
/// </summary>
/// <param name="Thumbprint">The thumbprint of the certificate to remove.</param>
public record RemoveServerCertCommand(string Thumbprint);
/// <param name="SiteIdentifier">
/// Target site identifier — REQUIRED when the command is invoked via the management
/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where
/// routing already happened. Additive per the message-contract evolution rules.
/// </param>
public record RemoveServerCertCommand(string Thumbprint, string? SiteIdentifier = null);
/// <summary>
/// List the certificates currently present in this site's trusted-peer and
/// rejected PKI stores. Answered from the local node (the Deployment Manager
/// singleton's own node).
/// </summary>
public record ListServerCertsCommand();
/// <param name="SiteIdentifier">
/// Target site identifier — REQUIRED when the command is invoked via the management
/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where
/// routing already happened. Additive per the message-contract evolution rules.
/// </param>
public record ListServerCertsCommand(string? SiteIdentifier = null);
/// <summary>
/// Read-only projection of a certificate found in a site PKI store.
@@ -3,5 +3,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListDatabaseConnectionsCommand;
public record GetDatabaseConnectionCommand(int DatabaseConnectionId);
public record CreateDatabaseConnectionDefCommand(string Name, string ConnectionString);
public record UpdateDatabaseConnectionDefCommand(int DatabaseConnectionId, string Name, string ConnectionString);
// ConnectionString is nullable (arch-review C3): an omitted (null) value preserves
// the stored secret rather than wiping it — the connection string is elided from
// List/Get responses, so a round-trip must not require re-sending it.
public record UpdateDatabaseConnectionDefCommand(int DatabaseConnectionId, string Name, string? ConnectionString);
public record DeleteDatabaseConnectionDefCommand(int DatabaseConnectionId);
@@ -1,6 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListInstancesCommand(int? SiteId = null, int? TemplateId = null, string? SearchTerm = null);
// Skip/Take are additive offset paging (arch-review P2), applied AFTER in-memory
// site-scope filtering. Take = null preserves the historical unpaged behaviour.
public record ListInstancesCommand(int? SiteId = null, int? TemplateId = null, string? SearchTerm = null, int Skip = 0, int? Take = null);
public record GetInstanceCommand(int InstanceId);
public record CreateInstanceCommand(string UniqueName, int TemplateId, int SiteId, int? AreaId = null);
public record MgmtDeployInstanceCommand(int InstanceId);
@@ -51,12 +51,16 @@ public record ApproveSecuredWriteCommand(long Id, string? Comment);
public record RejectSecuredWriteCommand(long Id, string? Comment);
/// <summary>
/// Read-only query for secured writes, optionally filtered by status and site.
/// A <c>null</c> filter matches every row.
/// Read-only query for secured writes, optionally filtered by status and site,
/// with offset paging. A <c>null</c> filter matches every row. The <paramref name="Skip"/>
/// / <paramref name="Take"/> defaults preserve the historical unpaged wire behaviour
/// (first 200 rows) for callers that omit them (additive evolution).
/// </summary>
/// <param name="Status">Status filter; <c>null</c> matches every status.</param>
/// <param name="SiteId">Site id filter; <c>null</c> matches every site.</param>
public record ListSecuredWritesCommand(string? Status, string? SiteId);
/// <param name="Skip">Number of rows to skip (offset paging); clamped to &gt;= 0 server-side.</param>
/// <param name="Take">Maximum rows to return; clamped to 1..500 server-side. Default 200.</param>
public record ListSecuredWritesCommand(string? Status, string? SiteId, int Skip = 0, int Take = 200);
/// <summary>
/// Result projection of a single pending secured write row, mirroring the
@@ -79,5 +83,11 @@ public record SecuredWriteDto(
DateTime? ExecutedAtUtc,
string? ExecutionError);
/// <summary>Result wrapper for <see cref="ListSecuredWritesCommand"/>.</summary>
public record SecuredWriteListResult(IReadOnlyList<SecuredWriteDto> Items);
/// <summary>
/// Result wrapper for <see cref="ListSecuredWritesCommand"/>. <paramref name="TotalCount"/>
/// is the count of rows matching the query filters ignoring paging, so the UI can render
/// a pager independent of the returned page size.
/// </summary>
/// <param name="Items">The page of matching secured-write rows.</param>
/// <param name="TotalCount">Total number of rows matching the filters (unpaged).</param>
public record SecuredWriteListResult(IReadOnlyList<SecuredWriteDto> Items, int TotalCount);
@@ -1,6 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListTemplatesCommand;
// Skip/Take are additive offset paging (arch-review P2). Take = null preserves the
// historical unpaged behaviour (unlimited), so existing callers/UI are untouched.
public record ListTemplatesCommand(int Skip = 0, int? Take = null);
public record GetTemplateCommand(int TemplateId);
public record CreateTemplateCommand(string Name, string? Description, int? ParentTemplateId);
@@ -12,7 +12,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// <param name="ConnectionName">Name of the data connection being verified (for logging/correlation).</param>
/// <param name="Protocol">Protocol type string (e.g. "OpcUa"); matched case-insensitively.</param>
/// <param name="ConfigJson">Serialized endpoint configuration JSON (the typed OPC UA endpoint shape).</param>
public record VerifyEndpointCommand(string ConnectionName, string Protocol, string ConfigJson);
/// <param name="SiteIdentifier">
/// Target site identifier — REQUIRED when the command is invoked via the management
/// HTTP API/CLI (the actor must route it to a site); ignored on the site side, where
/// routing already happened. Additive per the message-contract evolution rules.
/// </param>
public record VerifyEndpointCommand(string ConnectionName, string Protocol, string ConfigJson, string? SiteIdentifier = null);
/// <summary>
/// Classification of why an endpoint verification failed. Distinguishes the cases the
@@ -5,6 +5,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
/// Supports filtering by event type, severity, instance, time range, and keyword search.
/// Uses keyset pagination via continuation token (last event ID).
/// </summary>
/// <param name="InstanceId">
/// Instance filter matched against the site event log's <c>instance_id</c> column,
/// which stores the instance <b>UniqueName</b> (InstanceActor.LogLifecycleEvent passes
/// <c>_instanceUniqueName</c>; EventLogQueryService matches <c>instance_id = $instanceId</c>).
/// </param>
public record EventLogQueryRequest(
string CorrelationId,
string SiteId,
@@ -24,6 +24,14 @@ public enum AuditKind
SecuredWriteReject,
SecuredWriteExecute,
/// <summary>
/// A Pending secured write aged past its server-side TTL and was transitioned to
/// <c>Expired</c> without ever executing — the stale setpoint is never relayed to
/// the site. Emitted best-effort by the system (no verifier) when the approve/reject
/// path or the opportunistic list sweep observes an overdue Pending row. (arch-review S2)
/// </summary>
SecuredWriteExpire,
/// <summary>
/// A reconciliation pull row that failed to insert on every retry up to the
/// permanent-abandon threshold, so central advanced its cursor past it and
@@ -363,7 +363,7 @@ public class CommunicationService
/// <param name="command">The OPC UA browse command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The browse result (children + truncation flag + structured failure).</returns>
public Task<BrowseNodeResult> BrowseNodeAsync(
public virtual Task<BrowseNodeResult> BrowseNodeAsync(
string siteId,
BrowseNodeCommand command,
CancellationToken cancellationToken = default)
@@ -385,7 +385,7 @@ public class CommunicationService
/// <param name="command">The verify-endpoint command (connection name + protocol + serialized config).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The verification result (success or classified failure, with a captured certificate when the failure is an untrusted certificate).</returns>
public Task<VerifyEndpointResult> VerifyEndpointAsync(
public virtual Task<VerifyEndpointResult> VerifyEndpointAsync(
string siteId,
VerifyEndpointCommand command,
CancellationToken cancellationToken = default)
@@ -407,7 +407,7 @@ public class CommunicationService
/// <param name="command">The address-space search command (connection name + query + depth/result caps).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The search result (matches + cap-reached flag + structured failure).</returns>
public Task<SearchAddressSpaceResult> SearchAddressSpaceAsync(
public virtual Task<SearchAddressSpaceResult> SearchAddressSpaceAsync(
string siteId,
SearchAddressSpaceCommand command,
CancellationToken cancellationToken = default)
@@ -432,7 +432,7 @@ public class CommunicationService
/// <param name="command">The trust-server-cert command (connection name + DER + thumbprint).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cert-trust result (per-node aggregate success + first error).</returns>
public Task<CertTrustResult> TrustServerCertAsync(
public virtual Task<CertTrustResult> TrustServerCertAsync(
string siteId,
TrustServerCertCommand command,
CancellationToken cancellationToken = default)
@@ -453,7 +453,7 @@ public class CommunicationService
/// <param name="command">The list-server-certs command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cert-trust result carrying the listed certificates on success.</returns>
public Task<CertTrustResult> ListServerCertsAsync(
public virtual Task<CertTrustResult> ListServerCertsAsync(
string siteId,
ListServerCertsCommand command,
CancellationToken cancellationToken = default)
@@ -474,7 +474,7 @@ public class CommunicationService
/// <param name="command">The remove-server-cert command (thumbprint).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cert-trust result (per-node aggregate success + first error).</returns>
public Task<CertTrustResult> RemoveServerCertAsync(
public virtual Task<CertTrustResult> RemoveServerCertAsync(
string siteId,
RemoveServerCertCommand command,
CancellationToken cancellationToken = default)
@@ -106,4 +106,43 @@ WHERE Id = {id}
return rowsAffected == 1;
}
/// <inheritdoc />
public async Task<bool> TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default)
{
// Single-statement compare-and-swap: the conditional WHERE Status='Pending'
// makes the Pending->Expired transition atomic at the row level, so two nodes
// sweeping the same overdue write concurrently produce exactly one
// rowsAffected==1 (the winner) and one rowsAffected==0 (the loser). Expiry is
// a system decision — VerifierUser is left NULL. Same conditional-update
// pattern as TryMarkApprovedAsync.
var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
$@"UPDATE dbo.PendingSecuredWrites
SET Status = 'Expired',
DecidedAtUtc = {expiredAtUtc}
WHERE Id = {id}
AND Status = 'Pending';",
ct);
return rowsAffected == 1;
}
/// <inheritdoc />
public async Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default)
{
IQueryable<PendingSecuredWrite> query = _context.Set<PendingSecuredWrite>()
.AsNoTracking();
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(p => p.Status == status);
}
if (!string.IsNullOrWhiteSpace(siteId))
{
query = query.Where(p => p.SiteId == siteId);
}
return await query.CountAsync(ct);
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
@@ -224,7 +225,7 @@ public class ArtifactDeploymentService
/// <param name="user">The user initiating the deployment.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A summary of the artifact deployment results for all sites.</returns>
public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
public virtual async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
string user,
CancellationToken cancellationToken = default)
{
@@ -232,6 +233,38 @@ public class ArtifactDeploymentService
if (sites.Count == 0)
return Result<ArtifactDeploymentSummary>.Failure("No sites configured.");
return await DeployCoreAsync(sites, user, cancellationToken);
}
/// <summary>
/// Deploys system-wide artifacts to a single site (management-API --site-id path).
/// </summary>
/// <param name="siteId">The database identifier of the target site.</param>
/// <param name="user">The user initiating the deployment.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A summary of the artifact deployment result for the single site.</returns>
public virtual async Task<Result<ArtifactDeploymentSummary>> DeployToSiteAsync(
int siteId,
string user,
CancellationToken cancellationToken = default)
{
var site = await _siteRepo.GetSiteByIdAsync(siteId, cancellationToken);
if (site is null)
return Result<ArtifactDeploymentSummary>.Failure($"Site with ID {siteId} not found.");
return await DeployCoreAsync([site], user, cancellationToken);
}
/// <summary>
/// Core deployment path shared by the all-sites and single-site entry points.
/// Builds a per-site command with that site's data connections and deploys to
/// every supplied site under one logical deployment id.
/// </summary>
private async Task<Result<ArtifactDeploymentSummary>> DeployCoreAsync(
IReadOnlyList<Site> sites,
string user,
CancellationToken cancellationToken)
{
var deploymentId = Guid.NewGuid().ToString("N");
var perSiteResults = new Dictionary<string, SiteArtifactResult>();
@@ -0,0 +1,222 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
/// <summary>
/// Elides secret-bearing values inside opaque connection-config JSON blobs before
/// they leave the management boundary (responses AND audit afterState), mirroring
/// the SmtpConfigPublicShape/SmsConfigPublicShape pattern for structured entities.
/// A property is secret-bearing when its name contains one of the fragments below
/// (case-insensitive) and its value is a JSON string. MergeSentinels lets a client
/// round-trip a scrubbed config through update without wiping stored secrets.
/// </summary>
internal static class ConfigSecretScrubber
{
public const string Sentinel = "***REDACTED***";
private static readonly string[] SecretNameFragments =
["password", "secret", "token", "apikey", "credential", "passphrase"];
private static bool IsSecretName(string propertyName)
{
foreach (var fragment in SecretNameFragments)
{
if (propertyName.Contains(fragment, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <summary>
/// Replaces every secret-bearing string value (at any depth) with <see cref="Sentinel"/>.
/// Null/empty/parse-failure inputs are returned unchanged with hadSecrets=false.
/// </summary>
public static (string? scrubbed, bool hadSecrets) Scrub(string? json)
{
if (string.IsNullOrEmpty(json))
{
return (json, false);
}
JsonNode? root;
try
{
root = JsonNode.Parse(json);
}
catch (JsonException)
{
return (json, false);
}
if (root is null)
{
return (json, false);
}
var hadSecrets = ScrubNode(root, propertyIsSecret: false);
if (!hadSecrets)
{
return (json, false);
}
return (root.ToJsonString(), true);
}
private static bool ScrubNode(JsonNode? node, bool propertyIsSecret)
{
switch (node)
{
case JsonObject obj:
{
var had = false;
// Snapshot keys so we can reassign values while iterating.
foreach (var name in obj.Select(kvp => kvp.Key).ToList())
{
var child = obj[name];
if (child is JsonValue && IsSecretName(name) && child.GetValueKind() == JsonValueKind.String)
{
obj[name] = Sentinel;
had = true;
}
else
{
had |= ScrubNode(child, IsSecretName(name));
}
}
return had;
}
case JsonArray arr:
{
var had = false;
for (var i = 0; i < arr.Count; i++)
{
var child = arr[i];
if (propertyIsSecret && child is JsonValue && child.GetValueKind() == JsonValueKind.String)
{
arr[i] = Sentinel;
had = true;
}
else
{
had |= ScrubNode(child, propertyIsSecret);
}
}
return had;
}
default:
return false;
}
}
/// <summary>
/// Walks the incoming config; wherever a string value equals <see cref="Sentinel"/>,
/// substitutes the value at the same path in <paramref name="stored"/> (if absent, the
/// Sentinel is left in place — an update would store it verbatim, which is inert).
/// Null/empty/parse-failure inputs pass through unchanged.
/// </summary>
public static string? MergeSentinels(string? incoming, string? stored)
{
if (string.IsNullOrEmpty(incoming))
{
return incoming;
}
JsonNode? incomingRoot;
try
{
incomingRoot = JsonNode.Parse(incoming);
}
catch (JsonException)
{
return incoming;
}
if (incomingRoot is null)
{
return incoming;
}
JsonNode? storedRoot = null;
if (!string.IsNullOrEmpty(stored))
{
try
{
storedRoot = JsonNode.Parse(stored);
}
catch (JsonException)
{
storedRoot = null;
}
}
MergeNode(incomingRoot, storedRoot);
return incomingRoot.ToJsonString();
}
private static void MergeNode(JsonNode? incoming, JsonNode? stored)
{
switch (incoming)
{
case JsonObject obj:
{
var storedObj = stored as JsonObject;
foreach (var name in obj.Select(kvp => kvp.Key).ToList())
{
var child = obj[name];
JsonNode? storedChild = null;
storedObj?.TryGetPropertyValue(name, out storedChild);
if (child is JsonValue
&& child.GetValueKind() == JsonValueKind.String
&& child.GetValue<string>() == Sentinel)
{
if (storedChild is not null)
{
obj[name] = storedChild.DeepClone();
}
}
else
{
MergeNode(child, storedChild);
}
}
break;
}
case JsonArray arr:
{
var storedArr = stored as JsonArray;
for (var i = 0; i < arr.Count; i++)
{
var child = arr[i];
var storedChild = storedArr is not null && i < storedArr.Count ? storedArr[i] : null;
if (child is JsonValue
&& child.GetValueKind() == JsonValueKind.String
&& child.GetValue<string>() == Sentinel)
{
if (storedChild is not null)
{
arr[i] = storedChild.DeepClone();
}
}
else
{
MergeNode(child, storedChild);
}
}
break;
}
}
}
}
@@ -198,13 +198,15 @@ public class DebugStreamHub : Hub
// Use IHubContext for callbacks — the hub instance is transient (disposed after method returns),
// but IHubContext is a singleton that remains valid for the lifetime of the connection.
var hubClients = _hubContext.Clients;
// Capture the logger into a local: the callbacks outlive this (transient)
// hub instance, and ILogger is a singleton, so this is safe to hold.
var logger = _logger;
var session = await _debugStreamService.StartStreamAsync(
instanceId,
onEvent: evt =>
{
// Fire-and-forget — if the client disconnects, SendAsync will fail silently
_ = evt switch
var send = evt switch
{
AttributeValueChanged changed =>
hubClients.Client(connectionId).SendAsync("OnAttributeChanged", changed),
@@ -214,10 +216,20 @@ public class DebugStreamHub : Hub
hubClients.Client(connectionId).SendAsync("OnSnapshot", snapshot),
_ => Task.CompletedTask
};
// Fire-and-forget (deliberate — a slow/dead client must not block the
// stream), but log the push failure rather than swallowing it silently.
_ = send.ContinueWith(
t => logger.LogWarning(t.Exception?.GetBaseException(),
"Debug stream push to connection {ConnectionId} failed", connectionId),
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
},
onTerminated: () =>
{
_ = hubClients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected");
_ = hubClients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected")
.ContinueWith(
t => logger.LogWarning(t.Exception?.GetBaseException(),
"Debug stream termination push to connection {ConnectionId} failed", connectionId),
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
});
Context.Items[SessionIdKey] = session.SessionId;
@@ -5,6 +5,7 @@ using System.Text.Json.Serialization;
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
@@ -100,12 +101,14 @@ public class ManagementActor : ReceiveActor
var correlationId = envelope.CorrelationId;
var user = envelope.User;
// Check authorization
var requiredRole = GetRequiredRole(envelope.Command);
if (requiredRole != null && !user.Roles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase))
// Check authorization. A command may require ANY of a set of roles
// (any-of): the caller is authorized when they hold at least one.
var requiredRoles = GetRequiredRoles(envelope.Command);
if (requiredRoles is not null
&& !requiredRoles.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
{
sender.Tell(new ManagementUnauthorized(correlationId,
$"Role '{requiredRole}' required for {envelope.Command.GetType().Name}"));
$"One of roles [{string.Join(", ", requiredRoles)}] required for {envelope.Command.GetType().Name}"));
return;
}
@@ -157,7 +160,37 @@ public class ManagementActor : ReceiveActor
return new ManagementError(correlationId, clientMessage, "COMMAND_FAILED");
}
private static string? GetRequiredRole(object command) => command switch
// Static readonly role sets for the authorization gate. Single-role arms
// stay one-element arrays so every arm has the uniform any-of shape.
private static readonly string[] AdminOnly = [Roles.Administrator];
private static readonly string[] DesignerOnly = [Roles.Designer];
private static readonly string[] DeployerOnly = [Roles.Deployer];
private static readonly string[] OperatorOnly = [Roles.Operator];
private static readonly string[] VerifierOnly = [Roles.Verifier];
// arch-review C6: area management is reconciled to any-of [Designer, Deployer].
// Areas are authored on both real surfaces — the Designer template/topology
// tooling AND the Central UI Deployment Topology workflow (Topology.razor is
// RequireDeployment) — so a pure Deployer must be able to create/update/delete
// them. The three requirements docs' historical "Administrator" assignment was
// stale; Administrator is not implicitly included here, matching the actor's
// any-of treatment of every other Designer/Deployer command.
private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];
// arch-review UA1: the Secured Writes history table exposes
// process-sensitive tag values, so reading it is restricted to the
// two-person Secured Write roles plus Administrator (any-of) — NOT any
// authenticated user like the other list/query commands.
private static readonly string[] SecuredWriteReaders =
[Roles.Operator, Roles.Verifier, Roles.Administrator];
/// <summary>
/// Returns the set of roles that satisfy the authorization gate for
/// <paramref name="command"/> (any-of semantics), or <c>null</c> when the
/// command is available to any authenticated user. Internal so the
/// authorization matrix can be asserted directly in tests.
/// </summary>
internal static IReadOnlyList<string>? GetRequiredRoles(object command) => command switch
{
// Administrator operations
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
@@ -180,11 +213,20 @@ public class ManagementActor : ReceiveActor
// gate must match — otherwise a Designer blocked in the UI could still
// rotate a production credential via the CLI/Management API.
or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand => Roles.Administrator,
or UpdateSmsConfigCommand
// Server-certificate trust (arch-review C4). These mutate the site's
// trusted-peer PKI store — Admin, matching the Admin-gated UI cert
// page. Site-side reconcile semantics are owned by the site
// CertStoreActor (plan 03); this handler only relays.
or TrustServerCertCommand or RemoveServerCertCommand or ListServerCertsCommand => AdminOnly,
// Area management — any-of [Designer, Deployer] (arch-review C6).
// Exposed both in the Designer tooling and inside the Central UI
// Deployment Topology workflow (RequireDeployment), so both roles qualify.
CreateAreaCommand or UpdateAreaCommand or DeleteAreaCommand => AreaManagers,
// Designer operations
CreateAreaCommand or DeleteAreaCommand
or CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand
CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand
or ValidateTemplateCommand
or CreateExternalSystemCommand or UpdateExternalSystemCommand
or DeleteExternalSystemCommand
@@ -204,17 +246,21 @@ public class ManagementActor : ReceiveActor
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or UpdateAreaCommand
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand
or MoveTemplateToFolderCommand
or ExportBundleCommand => Roles.Designer,
// OPC UA / MxGateway design-time remote queries (arch-review C4):
// browse the address space, run a bounded search, and probe/verify an
// endpoint. Read-only against the live site server; gated Design to
// match the connection editor these back.
or BrowseNodeCommand or SearchAddressSpaceCommand or VerifyEndpointCommand
or ExportBundleCommand => DesignerOnly,
// Transport import operations (mirror the Central UI gating:
// Administrator for inbound bundle handling because they mutate
// cross-cutting configuration; Export stays Designer because it only
// reads).
PreviewBundleCommand or ImportBundleCommand => Roles.Administrator,
PreviewBundleCommand or ImportBundleCommand => AdminOnly,
// Deployer operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
@@ -226,16 +272,19 @@ public class ManagementActor : ReceiveActor
or MgmtDeployArtifactsCommand
or QueryDeploymentsCommand
or RetryParkedMessageCommand or DiscardParkedMessageCommand
or DebugSnapshotCommand => Roles.Deployer,
or DebugSnapshotCommand => DeployerOnly,
// Two-person secured write. Submit is an Operator action;
// approve/reject are Verifier actions. Separation of duties (a write may
// not be verified by its submitter) is enforced inside the handler — the
// role gate only ensures the caller holds the right coarse role.
SubmitSecuredWriteCommand => Roles.Operator,
RejectSecuredWriteCommand or ApproveSecuredWriteCommand => Roles.Verifier,
// ListSecuredWritesCommand is read-only -> falls through to "any
// authenticated user" below, like the other list/query commands.
SubmitSecuredWriteCommand => OperatorOnly,
RejectSecuredWriteCommand or ApproveSecuredWriteCommand => VerifierOnly,
// ListSecuredWritesCommand is read-only, but the history table exposes
// process-sensitive tag values, so it is NOT open to any authenticated
// user — restricted to the Secured Write roles + Administrator
// (arch-review UA1).
ListSecuredWritesCommand => SecuredWriteReaders,
// Read-only queries -- any authenticated user
_ => null
@@ -246,7 +295,7 @@ public class ManagementActor : ReceiveActor
return command switch
{
// Templates
ListTemplatesCommand => await HandleListTemplates(sp),
ListTemplatesCommand cmd => await HandleListTemplates(sp, cmd),
GetTemplateCommand cmd => await HandleGetTemplate(sp, cmd),
CreateTemplateCommand cmd => await HandleCreateTemplate(sp, cmd, user.Username),
UpdateTemplateCommand cmd => await HandleUpdateTemplate(sp, cmd, user.Username),
@@ -384,7 +433,7 @@ public class ManagementActor : ReceiveActor
DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd, user.Username),
// Deployments
MgmtDeployArtifactsCommand cmd => await HandleDeployArtifacts(sp, cmd, user.Username),
MgmtDeployArtifactsCommand cmd => await HandleDeployArtifacts(sp, cmd, user),
QueryDeploymentsCommand cmd => await HandleQueryDeployments(sp, cmd, user),
GetDeploymentDiffCommand cmd => await HandleGetDeploymentDiff(sp, cmd, user),
@@ -397,6 +446,12 @@ public class ManagementActor : ReceiveActor
// Remote Queries
QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd, user),
BrowseNodeCommand cmd => await HandleBrowseNode(sp, cmd, user),
SearchAddressSpaceCommand cmd => await HandleSearchAddressSpace(sp, cmd, user),
VerifyEndpointCommand cmd => await HandleVerifyEndpoint(sp, cmd, user),
TrustServerCertCommand cmd => await HandleTrustServerCert(sp, cmd, user),
RemoveServerCertCommand cmd => await HandleRemoveServerCert(sp, cmd, user),
ListServerCertsCommand cmd => await HandleListServerCerts(sp, cmd, user),
QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd, user),
RetryParkedMessageCommand cmd => await HandleRetryParkedMessage(sp, cmd, user),
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
@@ -504,12 +559,22 @@ public class ManagementActor : ReceiveActor
// Template handlers
// ========================================================================
private static async Task<object?> HandleListTemplates(IServiceProvider sp)
private static async Task<object?> HandleListTemplates(IServiceProvider sp, ListTemplatesCommand cmd)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
return await repo.GetAllTemplatesAsync();
var templates = await repo.GetAllTemplatesAsync();
return Page(templates, cmd.Skip, cmd.Take);
}
// Additive offset paging (arch-review P2). Take = null (or non-positive) keeps
// today's unlimited behaviour; Take is clamped to 1..1000; Skip floors at 0.
// Applied AFTER any in-memory scope filtering by the caller.
private static List<T> Page<T>(IEnumerable<T> source, int skip, int? take) =>
source
.Skip(Math.Max(0, skip))
.Take(take is > 0 and <= 1000 ? take.Value : int.MaxValue)
.ToList();
private static async Task<object?> HandleGetTemplate(IServiceProvider sp, GetTemplateCommand cmd)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
@@ -725,7 +790,9 @@ public class ManagementActor : ReceiveActor
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList();
}
return instances;
// Paging is applied AFTER the site-scope filter so a scoped user never sees
// an out-of-scope instance surface into their page window.
return Page(instances, cmd.Skip, cmd.Take);
}
private static async Task<object?> HandleGetInstance(IServiceProvider sp, GetInstanceCommand cmd, AuthenticatedUser user)
@@ -1075,6 +1142,28 @@ public class ManagementActor : ReceiveActor
}
}
/// <summary>If the Pending row is older than the configured TTL, CAS it to Expired
/// (multi-node idempotent), audit best-effort, and return true. A stale setpoint
/// must never be approvable days later (arch-review S2).</summary>
private static async Task<bool> TryExpireIfStaleAsync(
IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row)
{
var ttl = sp.GetService<IOptions<ManagementServiceOptions>>()?.Value.SecuredWritePendingTtl
?? TimeSpan.FromHours(24);
if (ttl <= TimeSpan.Zero || DateTime.UtcNow - row.SubmittedAtUtc <= ttl) return false;
var expiredAt = DateTime.UtcNow;
if (await repo.TryMarkExpiredAsync(row.Id, expiredAt))
{
row.Status = "Expired";
row.DecidedAtUtc = expiredAt;
// No Discarded-equivalent below Expired in the AuditStatus enum; Discarded is
// the closest terminal status (the write was abandoned, never executed).
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExpire, AuditStatus.Discarded, row, actor: "system");
}
return true;
}
private static async Task<object?> HandleSubmitSecuredWrite(
IServiceProvider sp, SubmitSecuredWriteCommand cmd, AuthenticatedUser user)
{
@@ -1128,6 +1217,14 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException(
$"Secured write {cmd.Id} is '{row.Status}', not Pending; it cannot be approved.");
// Server-side TTL: a Pending write older than the configured age is transitioned
// to Expired and can never be approved — the stale setpoint is never relayed
// (arch-review S2). Checked BEFORE the self-approval/CAS so an overdue row never
// executes, regardless of who touches it.
if (await TryExpireIfStaleAsync(sp, repo, row))
throw new ManagementCommandException(
$"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");
// Separation of duties: a write may not be verified by its submitter. Checked
// BEFORE the CAS so a self-approval never consumes the Pending->Approved
// transition.
@@ -1277,6 +1374,13 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException(
$"Secured write {cmd.Id} is '{entity.Status}', not Pending; it cannot be rejected.");
// Server-side TTL: an overdue Pending write expires here too, so a reject on a
// stale row deterministically reports the expiry rather than recording a
// human decision (arch-review S2).
if (await TryExpireIfStaleAsync(sp, repo, entity))
throw new ManagementCommandException(
$"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");
// Separation of duties: the verifier must differ from the submitter.
if (string.Equals(entity.OperatorUser, user.Username, StringComparison.OrdinalIgnoreCase))
throw new ManagementCommandException(
@@ -1302,8 +1406,30 @@ public class ManagementActor : ReceiveActor
IServiceProvider sp, ListSecuredWritesCommand cmd)
{
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip: 0, take: 200);
return new SecuredWriteListResult(rows.Select(ToSecuredWriteDto).ToList());
// Offset paging (arch-review P3): clamp untrusted paging inputs so a crafted
// command can neither request a negative offset nor an unbounded page.
var take = Math.Clamp(cmd.Take, 1, 500);
var skip = Math.Max(0, cmd.Skip);
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take);
var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId);
// Opportunistic expiry sweep (arch-review S2): every time the list is read, walk
// the page and expire any overdue Pending row. TryExpireIfStaleAsync self-filters
// by age (fresh rows are no-ops) and CAS-guards the transition, so this is
// deliberately lazy/opportunistic — the ManagementActor already runs on every
// central node, so a dedicated expiry-timer singleton would be redundant, and the
// compare-and-swap makes two nodes sweeping the same overdue row concurrently
// harmless (exactly one wins; the loser observes false and re-projects the row's
// freshly-Expired state). The mutation lands on the in-memory row before projection,
// so the caller sees Expired in the same response.
foreach (var row in rows)
{
if (string.Equals(row.Status, "Pending", StringComparison.Ordinal))
await TryExpireIfStaleAsync(sp, repo, row);
}
return new SecuredWriteListResult(rows.Select(ToSecuredWriteDto).ToList(), totalCount);
}
// ========================================================================
@@ -1419,12 +1545,37 @@ public class ManagementActor : ReceiveActor
// Data Connection handlers
// ========================================================================
/// <summary>
/// Secret-elided projection of a DataConnection (arch-review C3): the raw
/// PrimaryConfiguration/BackupConfiguration JSON can embed OPC UA user and
/// certificate passwords, and List/Get are readable by any authenticated user.
/// Mirrors <see cref="SmtpConfigPublicShape"/>. The secret-bearing config blobs
/// are scrubbed to the redaction sentinel; every non-config field is projected
/// verbatim so callers keep the full shape they had before.
/// </summary>
private static object DataConnectionPublicShape(DataConnection c)
{
var (primary, s1) = ConfigSecretScrubber.Scrub(c.PrimaryConfiguration);
var (backup, s2) = ConfigSecretScrubber.Scrub(c.BackupConfiguration);
return new
{
c.Id,
c.SiteId,
c.Name,
c.Protocol,
c.FailoverRetryCount,
PrimaryConfiguration = primary,
BackupConfiguration = backup,
HasSecrets = s1 || s2,
};
}
private static async Task<object?> HandleListDataConnections(IServiceProvider sp, ListDataConnectionsCommand cmd)
{
var repo = sp.GetRequiredService<ISiteRepository>();
if (cmd.SiteId.HasValue)
return await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value);
return await repo.GetAllDataConnectionsAsync();
return (await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value)).Select(DataConnectionPublicShape).ToList();
return (await repo.GetAllDataConnectionsAsync()).Select(DataConnectionPublicShape).ToList();
}
private static async Task<object?> HandleGetDataConnection(IServiceProvider sp, GetDataConnectionCommand cmd, AuthenticatedUser user)
@@ -1433,7 +1584,7 @@ public class ManagementActor : ReceiveActor
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId);
if (conn != null)
EnforceSiteScope(user, conn.SiteId);
return conn;
return conn is null ? null : DataConnectionPublicShape(conn);
}
private static async Task<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
@@ -1447,8 +1598,9 @@ public class ManagementActor : ReceiveActor
};
await repo.AddDataConnectionAsync(conn);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn);
return conn;
var publicShape = DataConnectionPublicShape(conn);
await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, publicShape);
return publicShape;
}
private static async Task<object?> HandleUpdateDataConnection(IServiceProvider sp, UpdateDataConnectionCommand cmd, string user)
@@ -1458,13 +1610,17 @@ public class ManagementActor : ReceiveActor
?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");
conn.Name = cmd.Name;
conn.Protocol = cmd.Protocol;
conn.PrimaryConfiguration = cmd.PrimaryConfiguration;
conn.BackupConfiguration = cmd.BackupConfiguration;
// Sentinel-preserving merge: a client that round-trips a scrubbed config
// (secrets replaced with the redaction sentinel) keeps the stored secret
// rather than wiping it. Non-sentinel values overwrite as usual.
conn.PrimaryConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.PrimaryConfiguration, conn.PrimaryConfiguration);
conn.BackupConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.BackupConfiguration, conn.BackupConfiguration);
conn.FailoverRetryCount = cmd.FailoverRetryCount;
await repo.UpdateDataConnectionAsync(conn);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, conn);
return conn;
var publicShape = DataConnectionPublicShape(conn);
await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, publicShape);
return publicShape;
}
private static async Task<object?> HandleDeleteDataConnection(IServiceProvider sp, DeleteDataConnectionCommand cmd, string user)
@@ -1605,16 +1761,36 @@ public class ManagementActor : ReceiveActor
// External System handlers
// ========================================================================
/// <summary>
/// Secret-elided ExternalSystemDefinition projection (arch-review C3): the
/// opaque <c>AuthConfiguration</c> blob holds API keys / Basic credentials and
/// must never leave this boundary via responses or audit afterState. Presence
/// is surfaced as <c>HasAuthConfiguration</c>; every non-secret field is
/// projected verbatim. Mirrors <see cref="SmtpConfigPublicShape"/>.
/// </summary>
private static object ExternalSystemPublicShape(ExternalSystemDefinition d) => new
{
d.Id,
d.Name,
d.EndpointUrl,
d.AuthType,
d.MaxRetries,
d.RetryDelay,
d.TimeoutSeconds,
HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration),
};
private static async Task<object?> HandleListExternalSystems(IServiceProvider sp)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetAllExternalSystemsAsync();
return (await repo.GetAllExternalSystemsAsync()).Select(ExternalSystemPublicShape).ToList();
}
private static async Task<object?> HandleGetExternalSystem(IServiceProvider sp, GetExternalSystemCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId);
var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId);
return def is null ? null : ExternalSystemPublicShape(def);
}
private static async Task<object?> HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd, string user)
@@ -1627,8 +1803,9 @@ public class ManagementActor : ReceiveActor
};
await repo.AddExternalSystemAsync(def);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, def);
return def;
var publicShape = ExternalSystemPublicShape(def);
await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
return publicShape;
}
private static async Task<object?> HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd, string user)
@@ -1639,12 +1816,15 @@ public class ManagementActor : ReceiveActor
def.Name = cmd.Name;
def.EndpointUrl = cmd.EndpointUrl;
def.AuthType = cmd.AuthType;
def.AuthConfiguration = cmd.AuthConfiguration;
// Preserve-if-null: an update that omits AuthConfiguration leaves the stored
// secret intact (mirrors the SMTP/SMS credential preserve-if-null rule).
if (cmd.AuthConfiguration is not null) def.AuthConfiguration = cmd.AuthConfiguration;
if (cmd.TimeoutSeconds is { } ts) def.TimeoutSeconds = ts;
await repo.UpdateExternalSystemAsync(def);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, def);
return def;
var publicShape = ExternalSystemPublicShape(def);
await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
return publicShape;
}
private static async Task<object?> HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd, string user)
@@ -1935,28 +2115,27 @@ public class ManagementActor : ReceiveActor
return await repo.GetAllMappingsAsync();
}
// An LDAP-group mapping's Role is a free string on the
// wire (CLI/API), so reject anything outside the canonical Roles.All set at the single
// server-side write path. A non-canonical role never functioned (no policy or authz
// check matches it), so rejecting it removes a silent-misconfiguration footgun rather
// than changing behaviour. Membership is checked case-insensitively to match the rest
// of the actor's role comparisons; the existing case-sensitivity asymmetry between the
// UI RequireClaim policies and the ManagementActor check is a separately-deferred change
// and is deliberately NOT altered here — the stored value's verbatim casing is preserved.
private static void ValidateMappingRole(string role)
// An LDAP-group mapping's Role is a free string on the wire (CLI/API), so both
// validate membership AND canonicalize casing at the single server-side write path.
// The stored Role must be CANONICAL casing (Roles.All): ASP.NET RequireClaim policies
// compare ordinal case-sensitively while the actor compares OrdinalIgnoreCase, so a
// row stored as "deployer" passes every actor gate but fails every UI policy — a
// CLI-works/UI-403s split-brain. Canonicalizing here closes the asymmetry (arch-review
// C5; closes the deferral formerly documented here). A role outside Roles.All never
// functioned (no policy or authz check matches it), so rejecting it removes a
// silent-misconfiguration footgun rather than changing behaviour.
private static string CanonicalizeMappingRole(string role)
{
if (!Roles.All.Contains(role, StringComparer.OrdinalIgnoreCase))
{
throw new ManagementCommandException(
$"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
}
var canonical = Roles.All.FirstOrDefault(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
return canonical ?? throw new ManagementCommandException(
$"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
}
private static async Task<object?> HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd, string user)
{
ValidateMappingRole(cmd.Role);
var role = CanonicalizeMappingRole(cmd.Role);
var repo = sp.GetRequiredService<ISecurityRepository>();
var mapping = new LdapGroupMapping(cmd.LdapGroupName, cmd.Role);
var mapping = new LdapGroupMapping(cmd.LdapGroupName, role);
await repo.AddMappingAsync(mapping);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping);
@@ -1965,12 +2144,12 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleUpdateRoleMapping(IServiceProvider sp, UpdateRoleMappingCommand cmd, string user)
{
ValidateMappingRole(cmd.Role);
var role = CanonicalizeMappingRole(cmd.Role);
var repo = sp.GetRequiredService<ISecurityRepository>();
var mapping = await repo.GetMappingByIdAsync(cmd.MappingId)
?? throw new ManagementCommandException($"RoleMapping with ID {cmd.MappingId} not found.");
mapping.LdapGroupName = cmd.LdapGroupName;
mapping.Role = cmd.Role;
mapping.Role = role;
await repo.UpdateMappingAsync(mapping);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping);
@@ -2037,10 +2216,24 @@ public class ManagementActor : ReceiveActor
// Deployment handlers
// ========================================================================
private static async Task<object?> HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, string user)
private static async Task<object?> HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, AuthenticatedUser user)
{
// A site-scoped Deployer may not trigger a FLEET-WIDE artifact deployment:
// EnforceSiteScope(null) is a no-op by design, so the fleet-wide case needs
// its own guard (arch-review C2 authorization gap).
if (cmd.SiteId is null
&& user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
throw new SiteScopeViolationException(
"Site-scoped users cannot deploy artifacts fleet-wide; specify a target site id.");
}
EnforceSiteScope(user, cmd.SiteId);
var svc = sp.GetRequiredService<ArtifactDeploymentService>();
var result = await svc.DeployToAllSitesAsync(user);
var result = cmd.SiteId is int siteId
? await svc.DeployToSiteAsync(siteId, user.Username)
: await svc.DeployToAllSitesAsync(user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
@@ -2557,16 +2750,30 @@ public class ManagementActor : ReceiveActor
// Database Connection Definition handlers
// ========================================================================
/// <summary>
/// Secret-elided DatabaseConnectionDefinition projection (arch-review C3): the
/// ADO.NET <c>ConnectionString</c> can embed the SQL password and must never
/// leave this boundary via List/Get responses. Presence is surfaced as
/// <c>HasConnectionString</c>. Mirrors <see cref="SmtpConfigPublicShape"/>.
/// </summary>
private static object DatabaseConnectionPublicShape(DatabaseConnectionDefinition d) => new
{
d.Id,
d.Name,
HasConnectionString = !string.IsNullOrEmpty(d.ConnectionString),
};
private static async Task<object?> HandleListDatabaseConnections(IServiceProvider sp)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetAllDatabaseConnectionsAsync();
return (await repo.GetAllDatabaseConnectionsAsync()).Select(DatabaseConnectionPublicShape).ToList();
}
private static async Task<object?> HandleGetDatabaseConnection(IServiceProvider sp, GetDatabaseConnectionCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId);
var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId);
return def is null ? null : DatabaseConnectionPublicShape(def);
}
private static async Task<object?> HandleCreateDatabaseConnection(IServiceProvider sp, CreateDatabaseConnectionDefCommand cmd, string user)
@@ -2576,7 +2783,7 @@ public class ManagementActor : ReceiveActor
await repo.AddDatabaseConnectionAsync(def);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
return def;
return DatabaseConnectionPublicShape(def);
}
private static async Task<object?> HandleUpdateDatabaseConnection(IServiceProvider sp, UpdateDatabaseConnectionDefCommand cmd, string user)
@@ -2585,11 +2792,14 @@ public class ManagementActor : ReceiveActor
var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId)
?? throw new ManagementCommandException($"DatabaseConnection with ID {cmd.DatabaseConnectionId} not found.");
def.Name = cmd.Name;
def.ConnectionString = cmd.ConnectionString;
// Preserve-if-null: an update that omits ConnectionString leaves the stored
// secret intact — the connection string is elided from List/Get, so a
// round-trip must not be required to re-send it.
if (cmd.ConnectionString is not null) def.ConnectionString = cmd.ConnectionString;
await repo.UpdateDatabaseConnectionAsync(def);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
return def;
return DatabaseConnectionPublicShape(def);
}
private static async Task<object?> HandleDeleteDatabaseConnection(IServiceProvider sp, DeleteDatabaseConnectionDefCommand cmd, string user)
@@ -2797,7 +3007,10 @@ public class ManagementActor : ReceiveActor
cmd.SiteIdentifier,
cmd.From, cmd.To,
cmd.EventType, cmd.Severity,
cmd.InstanceName, // InstanceId filter
// Instance unique-name filter — the site event log's instance_id column
// stores the instance UniqueName (InstanceActor.LogLifecycleEvent passes
// _instanceUniqueName; EventLogQueryService matches instance_id = $instanceId).
cmd.InstanceName,
cmd.Keyword,
null, // ContinuationToken
cmd.PageSize,
@@ -2805,6 +3018,72 @@ public class ManagementActor : ReceiveActor
return await commService.QueryEventLogsAsync(cmd.SiteIdentifier, request);
}
// OPC UA / MxGateway design-time remote queries (arch-review C4). These
// handlers give the management API/CLI parity with the Central UI, which
// already reaches these site actors directly. SiteIdentifier is REQUIRED —
// the actor must know which site to route to — and site-scope is enforced
// before relaying, exactly like HandleQueryEventLogs.
private static async Task<object?> HandleBrowseNode(IServiceProvider sp, BrowseNodeCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when browsing an address space via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.BrowseNodeAsync(site, cmd);
}
private static async Task<object?> HandleSearchAddressSpace(IServiceProvider sp, SearchAddressSpaceCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when searching an address space via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.SearchAddressSpaceAsync(site, cmd);
}
private static async Task<object?> HandleVerifyEndpoint(IServiceProvider sp, VerifyEndpointCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when verifying an endpoint via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.VerifyEndpointAsync(site, cmd);
}
// Server-certificate trust (arch-review C4). Admin-gated at the role check
// above. These mutate the site's trusted-peer PKI store — the handler only
// relays; the site-side broadcast/reconcile across both nodes is owned by
// the site CertStoreActor (plan 03). SiteIdentifier is REQUIRED so the actor
// knows which site to route to.
private static async Task<object?> HandleTrustServerCert(IServiceProvider sp, TrustServerCertCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when trusting a server certificate via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.TrustServerCertAsync(site, cmd);
}
private static async Task<object?> HandleRemoveServerCert(IServiceProvider sp, RemoveServerCertCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when removing a server certificate via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.RemoveServerCertAsync(site, cmd);
}
private static async Task<object?> HandleListServerCerts(IServiceProvider sp, ListServerCertsCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when listing server certificates via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.ListServerCertsAsync(site, cmd);
}
private static async Task<object?> HandleQueryParkedMessages(IServiceProvider sp, QueryParkedMessagesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
@@ -2839,6 +3118,14 @@ public class ManagementActor : ReceiveActor
// Transport bundle operations
// ========================================================================
/// <summary>Single-flight gate for transport bundle commands: a 200 MB import holds
/// ~4 concurrent whole-artifact copies (base64 string, byte[], MemoryStream, JSON envelope),
/// so two concurrent imports on one central node are a realistic OOM path (arch-review S4).
/// Commands queue here instead of running concurrently on the thread pool. Note the actor
/// pipes these handlers to the sender (HandleEnvelope), so without this gate two envelopes
/// received back-to-back run their handlers concurrently.</summary>
private static readonly SemaphoreSlim TransportGate = new(1, 1);
/// <summary>
/// Resolves the per-type name lists in <paramref name="cmd"/> against the
/// repositories, builds an <see cref="ExportSelection"/>, exports the bundle,
@@ -2847,6 +3134,9 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleExportBundle(
IServiceProvider sp, ExportBundleCommand cmd, string username)
{
await TransportGate.WaitAsync();
try
{
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
var notifRepo = sp.GetRequiredService<INotificationRepository>();
@@ -2937,10 +3227,19 @@ public class ManagementActor : ReceiveActor
selection, user: username, sourceEnvironment: cmd.SourceEnvironment,
passphrase: cmd.Passphrase);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
return new ExportBundleResult(Convert.ToBase64String(bytes), bytes.Length);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
// Encode directly off the MemoryStream's backing buffer rather than
// ms.ToArray() — the latter allocates a second whole-artifact copy.
// The long-term shape is streaming multipart to the caller (as the
// audit export path does, AuditEndpoints.cs:183+); deferred for now.
var base64 = Convert.ToBase64String(ms.GetBuffer().AsSpan(0, (int)ms.Length));
return new ExportBundleResult(base64, (int)ms.Length);
}
finally
{
TransportGate.Release();
}
}
/// <summary>
@@ -2950,33 +3249,41 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandlePreviewBundle(
IServiceProvider sp, PreviewBundleCommand cmd)
{
var importer = sp.GetRequiredService<IBundleImporter>();
var bytes = DecodeBundle(cmd.Base64Bundle);
BundleSession session;
await TransportGate.WaitAsync();
try
{
await using var stream = new MemoryStream(bytes);
session = await importer.LoadAsync(stream, cmd.Passphrase);
}
catch (ArgumentException ex)
{
throw new ManagementCommandException(ex.Message);
}
catch (System.Security.Cryptography.CryptographicException)
{
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
var importer = sp.GetRequiredService<IBundleImporter>();
var bytes = DecodeBundle(cmd.Base64Bundle);
BundleSession session;
try
{
await using var stream = new MemoryStream(bytes);
session = await importer.LoadAsync(stream, cmd.Passphrase);
}
catch (ArgumentException ex)
{
throw new ManagementCommandException(ex.Message);
}
catch (System.Security.Cryptography.CryptographicException)
{
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
var preview = await importer.PreviewAsync(session.SessionId);
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
// Surface the required site/connection mappings so an operator
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
var preview = await importer.PreviewAsync(session.SessionId);
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
// Surface the required site/connection mappings so an operator
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
}
finally
{
TransportGate.Release();
}
}
/// <summary>
@@ -2987,69 +3294,77 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleImportBundle(
IServiceProvider sp, ImportBundleCommand cmd, string username)
{
var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy);
var importer = sp.GetRequiredService<IBundleImporter>();
var bytes = DecodeBundle(cmd.Base64Bundle);
BundleSession session;
await TransportGate.WaitAsync();
try
{
await using var stream = new MemoryStream(bytes);
session = await importer.LoadAsync(stream, cmd.Passphrase);
}
catch (ArgumentException ex)
{
throw new ManagementCommandException(ex.Message);
}
catch (System.Security.Cryptography.CryptographicException)
{
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy);
var importer = sp.GetRequiredService<IBundleImporter>();
var bytes = DecodeBundle(cmd.Base64Bundle);
var preview = await importer.PreviewAsync(session.SessionId);
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
if (blockers.Count > 0)
{
var details = string.Join("; ",
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
throw new ManagementCommandException(
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
// Resolve every required site/connection reference into a
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
// fall back to the preview's auto-match; otherwise (no match) create-new
// ONLY if the create-missing flag is set, else fail with a clear message.
var nameMap = BuildNameMap(cmd, preview);
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
// DataConnection rows the preview item Name is already site-qualified
// ({site}/{name}), so keying generically by (EntityType, Name) is
// automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
BundleSession session;
try
{
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Identical => ResolutionAction.Skip,
ConflictKind.Modified => policy,
_ => ResolutionAction.Skip,
};
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
? $"{item.Name}-imported-{renameStamp}"
: null;
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
item.EntityType, item.Name, action, renameTo);
}
await using var stream = new MemoryStream(bytes);
session = await importer.LoadAsync(stream, cmd.Passphrase);
}
catch (ArgumentException ex)
{
throw new ManagementCommandException(ex.Message);
}
catch (System.Security.Cryptography.CryptographicException)
{
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
return await importer.ApplyAsync(
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
var preview = await importer.PreviewAsync(session.SessionId);
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
if (blockers.Count > 0)
{
var details = string.Join("; ",
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
throw new ManagementCommandException(
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
// Resolve every required site/connection reference into a
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
// fall back to the preview's auto-match; otherwise (no match) create-new
// ONLY if the create-missing flag is set, else fail with a clear message.
var nameMap = BuildNameMap(cmd, preview);
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
// DataConnection rows the preview item Name is already site-qualified
// ({site}/{name}), so keying generically by (EntityType, Name) is
// automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
{
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Identical => ResolutionAction.Skip,
ConflictKind.Modified => policy,
_ => ResolutionAction.Skip,
};
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
? $"{item.Name}-imported-{renameStamp}"
: null;
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
item.EntityType, item.Name, action, renameTo);
}
return await importer.ApplyAsync(
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
}
finally
{
TransportGate.Release();
}
}
/// <summary>
@@ -104,15 +104,30 @@ public static class ManagementAuthenticator
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
}
// 1a. Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5). Consulted BELOW the
// DisableLogin bypass — a login-disabled deployment never binds, so it never throttles.
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
return new AuthOutcome(null, Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429));
}
// 2. LDAP authentication
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
return new AuthOutcome(null, Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
}
throttle.RecordSuccess(username, remoteIp!);
// 3. Role resolution
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
@@ -32,6 +32,31 @@ public static class ManagementEndpoints
return DefaultAskTimeout;
}
private static readonly TimeSpan DefaultLongRunningTimeout = TimeSpan.FromMinutes(5);
/// <summary>Transport/deploy commands legitimately run for minutes (the CLI already
/// uses a 5-minute client timeout for bundles); a 30 s server-side Ask 504s while the
/// piped ProcessCommand keeps applying — the caller then retries and double-applies
/// (arch-review S1). These command types get LongRunningCommandTimeout instead.</summary>
private static readonly HashSet<Type> LongRunningCommands =
[
typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand),
];
/// <summary>
/// Resolves the ManagementActor Ask timeout for a specific command type. Long-running
/// transport/deploy commands use <see cref="ManagementServiceOptions.LongRunningCommandTimeout"/>
/// (default 5 min); all other commands fall through to <see cref="ResolveAskTimeout(ManagementServiceOptions?)"/>.
/// </summary>
/// <param name="options">The management service options, or <c>null</c> if not configured.</param>
/// <param name="commandType">The runtime type of the command being dispatched.</param>
/// <returns>The resolved Ask timeout for the command.</returns>
public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options, Type commandType)
=> LongRunningCommands.Contains(commandType)
? (options is { LongRunningCommandTimeout.Ticks: > 0 } o ? o.LongRunningCommandTimeout : DefaultLongRunningTimeout)
: ResolveAskTimeout(options);
/// <summary>Registers the <c>POST /management</c> endpoint on the given route builder.</summary>
/// <param name="endpoints">The route builder to add the endpoint to.</param>
/// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
@@ -99,7 +124,8 @@ public static class ManagementEndpoints
var envelope = new ManagementEnvelope(authenticatedUser, command, correlationId);
var askTimeout = ResolveAskTimeout(
context.RequestServices.GetService<IOptions<ManagementServiceOptions>>()?.Value);
context.RequestServices.GetService<IOptions<ManagementServiceOptions>>()?.Value,
command.GetType());
object response;
try
@@ -4,4 +4,10 @@ public class ManagementServiceOptions
{
/// <summary>Maximum time to wait for a management command to complete before returning a timeout error; default 30 seconds.</summary>
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Maximum time to wait for a long-running transport/deploy command (import/preview/export bundle, deploy artifacts/instance) before returning a timeout error; default 5 minutes.</summary>
public TimeSpan LongRunningCommandTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>Age after which a Pending secured write can no longer be approved; it is transitioned to Expired at the next decision/list touch. Default 24 hours. A non-positive value disables server-side expiry.</summary>
public TimeSpan SecuredWritePendingTtl { get; set; } = TimeSpan.FromHours(24);
}
@@ -8,6 +8,9 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.ManagementService.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka" />
<PackageReference Include="Akka.Cluster.Tools" />
@@ -88,6 +88,24 @@ public static class AuthorizationPolicies
/// </summary>
public const string RequireVerifier = "RequireVerifier";
/// <summary>
/// Read access to the Secured Writes surface (the Secured Writes page +
/// its Pending/history list). The history table exposes process-sensitive
/// tag values, so it is NOT open to any authenticated user — satisfied by
/// any of {<see cref="Roles.Operator"/>, <see cref="Roles.Verifier"/>,
/// <see cref="Roles.Administrator"/>} (arch-review UA1). Aligns with the
/// ManagementActor's <c>ListSecuredWritesCommand</c> any-of gate.
/// </summary>
public const string RequireSecuredWriteAccess = "RequireSecuredWriteAccess";
/// <summary>
/// Roles that satisfy <see cref="RequireSecuredWriteAccess"/>. Held in one
/// place so the policy, the docs, and the ManagementActor gate stay in
/// lockstep: {<c>Operator</c>, <c>Verifier</c>, <c>Administrator</c>}.
/// </summary>
public static readonly string[] SecuredWriteAccessRoles =
{ Roles.Operator, Roles.Verifier, Roles.Administrator };
/// <summary>
/// Read access to the Audit Log surface (Audit Log page,
/// Configuration Audit Log page, Audit nav group). Granted to the
@@ -155,6 +173,12 @@ public static class AuthorizationPolicies
options.AddPolicy(RequireVerifier, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Verifier));
// Any-of read gate for the Secured Writes surface (arch-review UA1):
// succeeds when the principal holds any of Operator/Verifier/
// Administrator, mirroring the ManagementActor ListSecuredWrites gate.
options.AddPolicy(RequireSecuredWriteAccess, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, SecuredWriteAccessRoles));
// Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with
// multiple allowed values is the right primitive: it checks
@@ -0,0 +1,166 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Fixed-window LDAP-bind failure lockout, keyed per <c>{username}|{ip}</c>.
/// </summary>
/// <remarks>
/// <para>
/// Every LDAP-bind surface (the Central UI <c>/auth/login</c> and <c>/auth/token</c>
/// endpoints, and the ManagementAuthenticator-fronted <c>POST /management</c>, audit REST,
/// and debug hub) records bind outcomes here and consults <see cref="IsLockedOut"/> before
/// attempting a bind. This blunts online password-spray / brute-force against the directory
/// without any external state — it is an in-memory, best-effort throttle per central node
/// (the two central nodes maintain independent counters; that is acceptable, since a
/// determined spray hitting both nodes still burns its budget twice as fast on each).
/// </para>
/// <para>
/// The counter uses a <b>fixed window</b>: the first failure opens a window of
/// <see cref="SecurityOptions.LoginFailureWindowMinutes"/>; reaching
/// <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/> failures inside that window locks
/// the key out for <see cref="SecurityOptions.LoginLockoutMinutes"/>. A successful bind or an
/// expired window resets the counter. Setting <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/>
/// to 0 disables throttling entirely.
/// </para>
/// <para>
/// Registered as a singleton (<see cref="ServiceCollectionExtensions.AddSecurity"/>) so the
/// counters are shared across all requests on a node. Thread-safe via
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> and atomic entry swaps. Memory is bounded:
/// each write opportunistically prunes expired entries, and if the map still exceeds
/// <see cref="MaxTrackedKeys"/> the oldest-windowed entries are dropped — so a spray across a
/// large IP/username space cannot grow the map without limit.
/// </para>
/// </remarks>
public sealed class LoginThrottle
{
/// <summary>
/// Hard cap on the number of tracked <c>{username}|{ip}</c> keys. Above this the
/// oldest-windowed entries are dropped on write to bound memory under a spray attack.
/// </summary>
public const int MaxTrackedKeys = 10_000;
private readonly TimeProvider _clock;
private readonly IOptions<SecurityOptions> _options;
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
/// <summary>
/// One tracked key's state: the failure <paramref name="Count"/>, the
/// <paramref name="WindowStart"/> that anchors the fixed failure window, and — once the
/// key is locked — the <paramref name="LockedUntil"/> instant the lockout expires.
/// </summary>
private readonly record struct Entry(int Count, DateTimeOffset WindowStart, DateTimeOffset? LockedUntil);
/// <summary>Creates the throttle over the injected clock and security options.</summary>
/// <param name="clock">Time source (wall clock in production; a fake in tests).</param>
/// <param name="options">Security options carrying the throttle thresholds.</param>
public LoginThrottle(TimeProvider clock, IOptions<SecurityOptions> options)
{
_clock = clock;
_options = options;
}
/// <summary>
/// Returns true when the given <paramref name="username"/> + <paramref name="ip"/> pair is
/// currently locked out and a bind must be refused without contacting LDAP. Never mutates
/// state (an expired lockout simply reads as unlocked; the entry is cleared on the next write).
/// </summary>
public bool IsLockedOut(string username, string ip)
{
var opts = _options.Value;
if (opts.MaxLoginFailuresPerWindow <= 0) return false; // throttling disabled
if (!_entries.TryGetValue(Key(username, ip), out var entry)) return false;
return entry.LockedUntil is { } until && until > _clock.GetUtcNow();
}
/// <summary>
/// Records a failed bind for the given <paramref name="username"/> + <paramref name="ip"/>.
/// Opens or advances the fixed window and, on reaching the configured threshold, arms the
/// lockout. No-op when throttling is disabled.
/// </summary>
public void RecordFailure(string username, string ip)
{
var opts = _options.Value;
if (opts.MaxLoginFailuresPerWindow <= 0) return; // throttling disabled
var now = _clock.GetUtcNow();
var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes);
var lockout = TimeSpan.FromMinutes(opts.LoginLockoutMinutes);
var key = Key(username, ip);
_entries.AddOrUpdate(
key,
// First observed failure for this key: open a fresh window.
_ => Arm(new Entry(1, now, null), opts, now, lockout),
(_, existing) =>
{
// A live lockout stands until it expires — further failures don't extend it.
if (existing.LockedUntil is { } until && until > now)
{
return existing;
}
// If the current window has lapsed (or a prior lockout has expired), restart it.
if (existing.LockedUntil is not null || now - existing.WindowStart >= window)
{
return Arm(new Entry(1, now, null), opts, now, lockout);
}
return Arm(existing with { Count = existing.Count + 1 }, opts, now, lockout);
});
Prune(now);
}
/// <summary>
/// Records a successful bind for the given <paramref name="username"/> +
/// <paramref name="ip"/>, clearing any accumulated failure count and lockout for that key.
/// </summary>
public void RecordSuccess(string username, string ip)
{
_entries.TryRemove(Key(username, ip), out _);
}
/// <summary>Arms the lockout on <paramref name="entry"/> if it has reached the threshold.</summary>
private static Entry Arm(Entry entry, SecurityOptions opts, DateTimeOffset now, TimeSpan lockout) =>
entry.Count >= opts.MaxLoginFailuresPerWindow
? entry with { LockedUntil = now + lockout }
: entry;
/// <summary>
/// Opportunistic memory management on write: drop entries whose window has fully expired
/// (no active lockout), and if the map still exceeds <see cref="MaxTrackedKeys"/> evict the
/// oldest-windowed entries so a spray across many keys cannot grow the map unbounded.
/// </summary>
private void Prune(DateTimeOffset now)
{
var opts = _options.Value;
var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes);
foreach (var kvp in _entries)
{
var e = kvp.Value;
var lockActive = e.LockedUntil is { } until && until > now;
if (!lockActive && now - e.WindowStart >= window)
{
_entries.TryRemove(kvp);
}
}
if (_entries.Count <= MaxTrackedKeys) return;
// Still over the cap after pruning expired entries — evict the oldest-windowed keys.
var excess = _entries.Count - MaxTrackedKeys;
foreach (var kvp in _entries.OrderBy(k => k.Value.WindowStart).Take(excess))
{
_entries.TryRemove(kvp);
}
}
private static string Key(string username, string ip) =>
$"{(username ?? string.Empty).ToLowerInvariant()}|{ip ?? string.Empty}";
}
@@ -104,6 +104,26 @@ public class SecurityOptions
/// <see cref="DefaultCookieName"/>. Changing this invalidates existing sessions on next deploy.
/// </summary>
public string CookieName { get; set; } = DefaultCookieName;
/// <summary>
/// Maximum failed LDAP binds tolerated per <see cref="LoginThrottle"/> fixed window for a
/// single <c>{username}|{ip}</c> pair before the pair is locked out. Default: <b>5</b>.
/// Set to <b>0</b> to disable login throttling entirely.
/// </summary>
public int MaxLoginFailuresPerWindow { get; set; } = 5;
/// <summary>
/// The <see cref="LoginThrottle"/> fixed-window length in minutes. Failures older than this
/// (measured from the window's first failure) no longer count toward the lockout threshold.
/// Default: <b>5</b>.
/// </summary>
public int LoginFailureWindowMinutes { get; set; } = 5;
/// <summary>
/// How long, in minutes, a <c>{username}|{ip}</c> pair stays locked out once it reaches
/// <see cref="MaxLoginFailuresPerWindow"/> failures within the window. Default: <b>5</b>.
/// </summary>
public int LoginLockoutMinutes { get; set; } = 5;
}
/// <summary>
@@ -137,6 +157,32 @@ public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IVal
$"enforcement is defeated.");
}
// SECURITY: login-throttle thresholds must be non-negative. 0 for
// MaxLoginFailuresPerWindow is the documented "disable throttling" escape hatch;
// a negative value (or a negative window/lockout) is a misconfiguration that would
// make the fixed-window math ill-defined, so it fails fast at boot.
if (options.MaxLoginFailuresPerWindow < 0)
{
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
$"{nameof(SecurityOptions.MaxLoginFailuresPerWindow)} " +
$"({options.MaxLoginFailuresPerWindow}) must be non-negative " +
$"(0 disables login throttling).");
}
if (options.LoginFailureWindowMinutes < 0)
{
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
$"{nameof(SecurityOptions.LoginFailureWindowMinutes)} " +
$"({options.LoginFailureWindowMinutes}) must be non-negative.");
}
if (options.LoginLockoutMinutes < 0)
{
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
$"{nameof(SecurityOptions.LoginLockoutMinutes)} " +
$"({options.LoginLockoutMinutes}) must be non-negative.");
}
return Microsoft.Extensions.Options.ValidateOptionsResult.Success;
}
}
@@ -99,6 +99,12 @@ public static class ServiceCollectionExtensions
services.AddOptions<SecurityOptions>().ValidateOnStart();
services.AddSingleton<IValidateOptions<SecurityOptions>, SecurityOptionsValidator>();
// Fixed-window LDAP-bind failure lockout (arch-review P1). A singleton so its
// per-{username|ip} failure counters are shared across every request on this node;
// consulted before, and updated after, every LDAP-bind surface (login/token endpoints
// and the ManagementAuthenticator-fronted management/audit/debug paths).
services.AddSingleton<LoginThrottle>();
// Note: the old SecurityOptionsValidator (which fail-fast-validated LdapServer +
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
@@ -151,6 +151,41 @@ public class CommandTreeTests
Assert.Contains("clear", subNames);
}
[Fact]
public void TemplateList_HasSkipAndTakePagingOptions()
{
// arch-review P2: `template list` gained additive --skip/--take offset paging.
var template = TemplateCommands.Build(Url, Format, Username, Password);
var list = template.Subcommands.Single(c => c.Name == "list");
var optionNames = list.Options.Select(o => o.Name).ToList();
Assert.Contains("--skip", optionNames);
Assert.Contains("--take", optionNames);
}
[Fact]
public void InstanceList_HasSkipAndTakePagingOptions()
{
// arch-review P2: `instance list` gained additive --skip/--take offset paging.
var instance = InstanceCommands.Build(Url, Format, Username, Password);
var list = instance.Subcommands.Single(c => c.Name == "list");
var optionNames = list.Options.Select(o => o.Name).ToList();
Assert.Contains("--skip", optionNames);
Assert.Contains("--take", optionNames);
}
[Fact]
public void InstanceList_ParsesTakeOptionValue()
{
// Parse-level check that --take binds to an int value on the list command.
var instance = InstanceCommands.Build(Url, Format, Username, Password);
var list = instance.Subcommands.Single(c => c.Name == "list");
var takeOption = list.Options.Single(o => o.Name == "--take");
var parse = list.Parse(new[] { "--skip", "10", "--take", "5" });
Assert.Empty(parse.Errors);
Assert.Equal(5, parse.GetValue((Option<int?>)takeOption));
}
[Theory]
[InlineData(typeof(GetInstanceCommand))]
[InlineData(typeof(ListSitesCommand))]
@@ -0,0 +1,62 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// PLAN-07 Task 17 (arch-review P3): <c>secured-write list</c> must expose <c>--skip</c>
/// / <c>--take</c> offset-paging options alongside the <c>--status</c> / <c>--site</c>
/// filters, and map them onto <see cref="Commons.Messages.Management.ListSecuredWritesCommand"/>.
/// Omitting the paging flags preserves the historical unpaged default (skip 0, take 200).
/// </summary>
public class SecuredWriteListCommandTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Format = new("--format") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static Command Group() => SecuredWriteCommands.Build(Url, Format, Username, Password);
private static Command List() => Group().Subcommands.Single(c => c.Name == "list");
private static Option? FindOption(Command command, string name)
=> command.Options.SingleOrDefault(o => o.Name == name);
[Fact]
public void List_HasSkipAndTakeOptions()
{
var list = List();
Assert.NotNull(FindOption(list, "--skip"));
Assert.NotNull(FindOption(list, "--take"));
}
[Fact]
public void List_ParsesSkipTakeAndFilters_ForwardsToCommand()
{
var parse = Group().Parse(new[]
{
"list", "--status", "Pending", "--site", "SITE1", "--skip", "10", "--take", "25",
});
Assert.Empty(parse.Errors);
var command = SecuredWriteCommands.BuildListCommand(parse);
Assert.Equal("Pending", command.Status);
Assert.Equal("SITE1", command.SiteId);
Assert.Equal(10, command.Skip);
Assert.Equal(25, command.Take);
}
[Fact]
public void List_OmittedPaging_UsesUnpagedDefaults()
{
var parse = Group().Parse(new[] { "list" });
Assert.Empty(parse.Errors);
var command = SecuredWriteCommands.BuildListCommand(parse);
Assert.Null(command.Status);
Assert.Null(command.SiteId);
Assert.Equal(0, command.Skip);
Assert.Equal(200, command.Take);
}
}
@@ -0,0 +1,193 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Parse tests for the site-routed data-connection subcommands added for actor parity
/// (arch-review C4): <c>browse</c>, <c>search</c>, <c>verify-endpoint</c>, and
/// <c>certs list|trust|remove</c>. Each test parses a real command line through
/// System.CommandLine and asserts the exact management command record produced,
/// including the routed <c>--site</c> → <c>SiteIdentifier</c> mapping.
/// </summary>
public class ConnectionBrowseCommandsTests
{
/// <summary>
/// Builds a root command with the browse/search/verify/certs subcommands attached
/// under a bare <c>data-connection</c> group. The interceptor captures the built
/// command record instead of sending it over HTTP, so the tests never need a live
/// management server.
/// </summary>
private static (RootCommand root, List<object> captured) BuildHarness()
{
var url = new Option<string>("--url") { Recursive = true };
var username = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
var format = CliOptions.CreateFormatOption();
var root = new RootCommand();
root.Add(url);
root.Add(username);
root.Add(password);
root.Add(format);
var captured = new List<object>();
var dataConnection = new Command("data-connection");
ConnectionBrowseCommands.AddTo(dataConnection, url, format, username, password, cmd =>
{
captured.Add(cmd);
return 0;
});
root.Add(dataConnection);
return (root, captured);
}
[Fact]
public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier()
{
var (root, captured) = BuildHarness();
var exit = await root
.Parse(new[] { "data-connection", "browse", "--site", "SITE1", "--connection", "conn1", "--node-id", "ns=2;s=X" })
.InvokeAsync();
Assert.Equal(0, exit);
var cmd = Assert.IsType<BrowseNodeCommand>(Assert.Single(captured));
Assert.Equal("SITE1", cmd.SiteIdentifier);
Assert.Equal("conn1", cmd.ConnectionName);
Assert.Equal("ns=2;s=X", cmd.ParentNodeId);
}
[Fact]
public async Task Browse_WithoutNodeId_BrowsesFromRoot()
{
var (root, captured) = BuildHarness();
var exit = await root
.Parse(new[] { "data-connection", "browse", "--site", "SITE1", "--connection", "conn1" })
.InvokeAsync();
Assert.Equal(0, exit);
var cmd = Assert.IsType<BrowseNodeCommand>(Assert.Single(captured));
Assert.Equal("SITE1", cmd.SiteIdentifier);
Assert.Null(cmd.ParentNodeId);
}
[Fact]
public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier()
{
var (root, captured) = BuildHarness();
var exit = await root
.Parse(new[]
{
"data-connection", "search", "--site", "SITE1", "--connection", "conn1",
"--query", "Motor", "--max-depth", "3", "--max-results", "50"
})
.InvokeAsync();
Assert.Equal(0, exit);
var cmd = Assert.IsType<SearchAddressSpaceCommand>(Assert.Single(captured));
Assert.Equal("SITE1", cmd.SiteIdentifier);
Assert.Equal("conn1", cmd.ConnectionName);
Assert.Equal("Motor", cmd.Query);
Assert.Equal(3, cmd.MaxDepth);
Assert.Equal(50, cmd.MaxResults);
}
[Fact]
public async Task VerifyEndpoint_ReadsConfigJsonFromFile_WithSiteIdentifier()
{
var (root, captured) = BuildHarness();
var configPath = Path.GetTempFileName();
const string configJson = "{\"endpointUrl\":\"opc.tcp://server:4840\"}";
await File.WriteAllTextAsync(configPath, configJson);
try
{
var exit = await root
.Parse(new[]
{
"data-connection", "verify-endpoint", "--site", "SITE1",
"--protocol", "OpcUa", "--config-file", configPath
})
.InvokeAsync();
Assert.Equal(0, exit);
var cmd = Assert.IsType<VerifyEndpointCommand>(Assert.Single(captured));
Assert.Equal("SITE1", cmd.SiteIdentifier);
Assert.Equal("OpcUa", cmd.Protocol);
Assert.Equal(configJson, cmd.ConfigJson);
}
finally
{
File.Delete(configPath);
}
}
[Fact]
public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier()
{
var (root, captured) = BuildHarness();
var exit = await root
.Parse(new[] { "data-connection", "certs", "list", "--site", "SITE1", "--connection", "conn1" })
.InvokeAsync();
Assert.Equal(0, exit);
var cmd = Assert.IsType<ListServerCertsCommand>(Assert.Single(captured));
Assert.Equal("SITE1", cmd.SiteIdentifier);
}
[Fact]
public async Task CertsTrust_Base64EncodesDerFile_WithSiteIdentifier()
{
var (root, captured) = BuildHarness();
var derPath = Path.GetTempFileName();
var derBytes = new byte[] { 0x30, 0x82, 0x01, 0x0A, 0xDE, 0xAD, 0xBE, 0xEF };
await File.WriteAllBytesAsync(derPath, derBytes);
try
{
var exit = await root
.Parse(new[]
{
"data-connection", "certs", "trust", "--site", "SITE1", "--connection", "conn1",
"--der-file", derPath, "--thumbprint", "AABBCC"
})
.InvokeAsync();
Assert.Equal(0, exit);
var cmd = Assert.IsType<TrustServerCertCommand>(Assert.Single(captured));
Assert.Equal("SITE1", cmd.SiteIdentifier);
Assert.Equal("conn1", cmd.ConnectionName);
Assert.Equal("AABBCC", cmd.Thumbprint);
Assert.Equal(Convert.ToBase64String(derBytes), cmd.DerBase64);
}
finally
{
File.Delete(derPath);
}
}
[Fact]
public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier()
{
var (root, captured) = BuildHarness();
var exit = await root
.Parse(new[]
{
"data-connection", "certs", "remove", "--site", "SITE1", "--connection", "conn1", "--thumbprint", "AABBCC"
})
.InvokeAsync();
Assert.Equal(0, exit);
var cmd = Assert.IsType<RemoveServerCertCommand>(Assert.Single(captured));
Assert.Equal("SITE1", cmd.SiteIdentifier);
Assert.Equal("AABBCC", cmd.Thumbprint);
}
}
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.RegularExpressions;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
@@ -48,10 +49,17 @@ public class SecuredWritesTests : BunitContext
.Returns(Task.FromResult(true));
// Empty list by default; individual tests seed the queue/history.
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(Array.Empty<SecuredWriteDto>()));
_service.ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0)));
}
/// <summary>Stubs <c>ListAsync</c> (any filter/paging args) to return <paramref name="rows"/>.</summary>
private void SeedList(IReadOnlyList<SecuredWriteDto> rows, int? totalCount = null)
=> _service.ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new SecuredWriteListResult(rows, totalCount ?? rows.Count)));
/// <summary>
/// Registers the real authorization stack + a principal with the given username and
/// roles so the page's <c>AuthorizeView Policy=...</c> regions evaluate genuinely.
@@ -165,9 +173,7 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(1, "Pending", "someone-else") }));
SeedList(new[] { Dto(1, "Pending", "someone-else") });
var cut = RenderPage();
@@ -189,9 +195,7 @@ public class SecuredWritesTests : BunitContext
// this too, but the UI surfaces it up front).
AddAuth("self", Roles.Operator, Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(1, "Pending", "self") }));
SeedList(new[] { Dto(1, "Pending", "self") });
var cut = RenderPage();
@@ -210,9 +214,7 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(7, "Pending", "someone-else") }));
SeedList(new[] { Dto(7, "Pending", "someone-else") });
_service.ApproveAsync(Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(7, "Executed", "someone-else", "verifier-user"))));
@@ -229,14 +231,13 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("viewer", Roles.Viewer);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(new[]
{
Dto(1, "Executed", "op-a", "ver-a", tag: "Pump.Setpoint"),
Dto(2, "Rejected", "op-b", "ver-b", tag: "Valve.Cmd"),
Dto(3, "Failed", "op-c", "ver-c", executionError: "device timeout", tag: "Motor.Run"),
Dto(4, "Pending", "op-d", tag: "ShouldNotAppear"),
}));
SeedList(new[]
{
Dto(1, "Executed", "op-a", "ver-a", tag: "Pump.Setpoint"),
Dto(2, "Rejected", "op-b", "ver-b", tag: "Valve.Cmd"),
Dto(3, "Failed", "op-c", "ver-c", executionError: "device timeout", tag: "Motor.Run"),
Dto(4, "Pending", "op-d", tag: "ShouldNotAppear"),
});
var cut = RenderPage();
@@ -253,6 +254,58 @@ public class SecuredWritesTests : BunitContext
});
}
// ── (d) History pager: page-size 50, driven by TotalCount ──
[Fact]
public void History_Pager_NextRequestsSecondPage_UsingTotalCount()
{
// 60 terminal rows returned but TotalCount = 120 → a second page exists, so the
// "Next" button is present + enabled and clicking it re-queries with skip: 50.
AddAuth("viewer", Roles.Viewer);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
var terminal = Enumerable.Range(1, 60)
.Select(i => Dto(i, "Executed", $"op-{i}", $"ver-{i}"))
.ToArray();
SeedList(terminal, totalCount: 120);
var cut = RenderPage();
cut.WaitForAssertion(() =>
{
var next = cut.Find("[data-test='secured-write-history-next']");
Assert.False(next.HasAttribute("disabled"));
});
cut.Find("[data-test='secured-write-history-next']").Click();
// The history page-2 fetch offsets by one page (skip: 50).
_service.Received().ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), 50, Arg.Any<int>(), Arg.Any<CancellationToken>());
}
// ── (e) Approve dialog surfaces submission age (stale-setpoint scenario) ──
[Fact]
public void Approve_ConfirmDialog_ShowsSubmissionAge()
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
var stale = Dto(9, "Pending", "someone-else") with { SubmittedAtUtc = DateTime.UtcNow.AddHours(-26) };
SeedList(new[] { stale });
_service.ApproveAsync(Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(9, "Executed", "someone-else", "verifier-user"))));
var cut = RenderPage();
cut.WaitForAssertion(() => Assert.NotNull(cut.Find("[data-test='secured-write-approve']")));
cut.Find("[data-test='secured-write-approve']").Click();
_dialog.Received(1).ConfirmAsync(
Arg.Any<string>(),
Arg.Is<string>(m => Regex.IsMatch(m, "Submitted .* ago")),
Arg.Any<bool>());
}
[Fact]
public void Viewer_DoesNotSeeOperatorOrVerifierRegions()
{
@@ -0,0 +1,112 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// bUnit rendering tests for the Operator Alarm Summary page — arch-review UA6
/// (sortable-header accessibility). The sortable column headers must be keyboard
/// operable: they carry <c>tabindex="0"</c>, expose their sort direction via
/// <c>aria-sort</c> (<c>ascending</c>/<c>descending</c> on the active column,
/// <c>none</c> otherwise), and toggle the sort when Enter is pressed on them —
/// mirroring the existing pointer-click sort.
/// </summary>
public class AlarmSummaryRenderTests : BunitContext
{
private readonly IAlarmSummaryService _summary = Substitute.For<IAlarmSummaryService>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
public AlarmSummaryRenderTests()
{
// Two rows whose default sort (severity DESC) orders Zeta (900) before
// Alpha (500); an ascending sort by instance name flips them.
var rows = new List<AlarmSummaryRow>
{
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
new("Alpha", new AlarmStateChanged("Alpha", "A-alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)),
};
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
_summary.ComputeRollup(Arg.Any<IReadOnlyList<AlarmSummaryRow>>())
.Returns(new AlarmRollup(2, 900, 0, new Dictionary<AlarmKind, int>()));
Services.AddSingleton(_summary);
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { new("Site 1", "site1") { Id = 1 } }));
Services.AddSingleton(_siteRepo);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
// Renders the page and drives the site picker so the alarm table appears.
private IRenderedComponent<AlarmSummaryPage> RenderWithSiteSelected()
{
var cut = Render<AlarmSummaryPage>();
cut.Find("[data-test='alarm-summary-site']").Change("1");
cut.WaitForAssertion(() =>
Assert.NotEmpty(cut.FindAll("tr[data-test='alarm-summary-row']")));
return cut;
}
[Fact]
public void SortableHeaders_CarryTabindexAndAriaSort()
{
var cut = RenderWithSiteSelected();
var headers = cut.FindAll("thead th");
// 0=Instance (sortable), 1=Alarm (sortable), 2=State/Kind (not sortable), 3=Severity (sortable, active).
var instance = headers[0];
var stateKind = headers[2];
var severity = headers[3];
Assert.Equal("0", instance.GetAttribute("tabindex"));
Assert.Equal("0", severity.GetAttribute("tabindex"));
// Default sort is severity descending.
Assert.Equal("descending", severity.GetAttribute("aria-sort"));
Assert.Equal("none", instance.GetAttribute("aria-sort"));
// The non-sortable column is not keyboard-focusable and carries no aria-sort.
Assert.Null(stateKind.GetAttribute("tabindex"));
Assert.Null(stateKind.GetAttribute("aria-sort"));
}
[Fact]
public void EnterOnInstanceHeader_FlipsRowOrder()
{
var cut = RenderWithSiteSelected();
// Default severity-descending order → Zeta (900) first.
Assert.Equal("Zeta", FirstRowInstance(cut));
// Enter on the Instance header sorts ascending by instance → Alpha first.
cut.FindAll("thead th")[0].TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Enter" });
cut.WaitForAssertion(() => Assert.Equal("Alpha", FirstRowInstance(cut)));
// aria-sort now reflects the active ascending instance column.
Assert.Equal("ascending", cut.FindAll("thead th")[0].GetAttribute("aria-sort"));
}
private static string FirstRowInstance(IRenderedComponent<AlarmSummaryPage> cut) =>
cut.FindAll("tr[data-test='alarm-summary-row']")[0]
.QuerySelector("td")!.TextContent.Trim();
}
@@ -0,0 +1,17 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
public class PollGateTests
{
[Fact]
public void TryEnter_SecondCallWhileHeld_ReturnsFalse()
{
var gate = new PollGate();
Assert.True(gate.TryEnter());
Assert.False(gate.TryEnter());
gate.Exit();
Assert.True(gate.TryEnter());
}
}
@@ -0,0 +1,134 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Task 21 (PLAN-07): the additive optional <c>SiteIdentifier</c> trailing param on the
/// Browse/Verify/Cert-trust management commands. Verifies backward compatibility (legacy
/// JSON without the field deserializes to null) and round-trip survival when set.
/// </summary>
public class BrowseVerifyCertSiteIdentifierTests
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true
};
// ── Backward compatibility: legacy JSON (no SiteIdentifier) → null ──
[Fact]
public void BrowseNodeCommand_LegacyJson_SiteIdentifierNull()
{
var json = """{"ConnectionName":"conn","ParentNodeId":"ns=2;s=Node","ContinuationToken":null}""";
var cmd = JsonSerializer.Deserialize<BrowseNodeCommand>(json, Options)!;
Assert.Null(cmd.SiteIdentifier);
Assert.Equal("conn", cmd.ConnectionName);
}
[Fact]
public void SearchAddressSpaceCommand_LegacyJson_SiteIdentifierNull()
{
var json = """{"ConnectionName":"conn","Query":"pump","MaxDepth":5,"MaxResults":100}""";
var cmd = JsonSerializer.Deserialize<SearchAddressSpaceCommand>(json, Options)!;
Assert.Null(cmd.SiteIdentifier);
Assert.Equal("pump", cmd.Query);
}
[Fact]
public void VerifyEndpointCommand_LegacyJson_SiteIdentifierNull()
{
var json = """{"ConnectionName":"conn","Protocol":"OpcUa","ConfigJson":"{}"}""";
var cmd = JsonSerializer.Deserialize<VerifyEndpointCommand>(json, Options)!;
Assert.Null(cmd.SiteIdentifier);
Assert.Equal("OpcUa", cmd.Protocol);
}
[Fact]
public void TrustServerCertCommand_LegacyJson_SiteIdentifierNull()
{
var json = """{"ConnectionName":"conn","DerBase64":"AAAA","Thumbprint":"ABCD"}""";
var cmd = JsonSerializer.Deserialize<TrustServerCertCommand>(json, Options)!;
Assert.Null(cmd.SiteIdentifier);
Assert.Equal("ABCD", cmd.Thumbprint);
}
[Fact]
public void RemoveServerCertCommand_LegacyJson_SiteIdentifierNull()
{
var json = """{"Thumbprint":"ABCD"}""";
var cmd = JsonSerializer.Deserialize<RemoveServerCertCommand>(json, Options)!;
Assert.Null(cmd.SiteIdentifier);
Assert.Equal("ABCD", cmd.Thumbprint);
}
[Fact]
public void ListServerCertsCommand_LegacyJson_SiteIdentifierNull()
{
var json = "{}";
var cmd = JsonSerializer.Deserialize<ListServerCertsCommand>(json, Options)!;
Assert.Null(cmd.SiteIdentifier);
}
// ── Round-trip: SiteIdentifier set survives serialize → deserialize ──
[Fact]
public void BrowseNodeCommand_RoundTrip_SiteIdentifierSurvives()
{
var original = new BrowseNodeCommand("conn", "ns=2;s=Node", "tok", "SITE-01");
var json = JsonSerializer.Serialize(original, Options);
var round = JsonSerializer.Deserialize<BrowseNodeCommand>(json, Options)!;
Assert.Equal("SITE-01", round.SiteIdentifier);
Assert.Equal(original, round);
}
[Fact]
public void SearchAddressSpaceCommand_RoundTrip_SiteIdentifierSurvives()
{
var original = new SearchAddressSpaceCommand("conn", "pump", 5, 100, "SITE-01");
var json = JsonSerializer.Serialize(original, Options);
var round = JsonSerializer.Deserialize<SearchAddressSpaceCommand>(json, Options)!;
Assert.Equal("SITE-01", round.SiteIdentifier);
Assert.Equal(original, round);
}
[Fact]
public void VerifyEndpointCommand_RoundTrip_SiteIdentifierSurvives()
{
var original = new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE-01");
var json = JsonSerializer.Serialize(original, Options);
var round = JsonSerializer.Deserialize<VerifyEndpointCommand>(json, Options)!;
Assert.Equal("SITE-01", round.SiteIdentifier);
Assert.Equal(original, round);
}
[Fact]
public void TrustServerCertCommand_RoundTrip_SiteIdentifierSurvives()
{
var original = new TrustServerCertCommand("conn", "AAAA", "ABCD", "SITE-01");
var json = JsonSerializer.Serialize(original, Options);
var round = JsonSerializer.Deserialize<TrustServerCertCommand>(json, Options)!;
Assert.Equal("SITE-01", round.SiteIdentifier);
Assert.Equal(original, round);
}
[Fact]
public void RemoveServerCertCommand_RoundTrip_SiteIdentifierSurvives()
{
var original = new RemoveServerCertCommand("ABCD", "SITE-01");
var json = JsonSerializer.Serialize(original, Options);
var round = JsonSerializer.Deserialize<RemoveServerCertCommand>(json, Options)!;
Assert.Equal("SITE-01", round.SiteIdentifier);
Assert.Equal(original, round);
}
[Fact]
public void ListServerCertsCommand_RoundTrip_SiteIdentifierSurvives()
{
var original = new ListServerCertsCommand("SITE-01");
var json = JsonSerializer.Serialize(original, Options);
var round = JsonSerializer.Deserialize<ListServerCertsCommand>(json, Options)!;
Assert.Equal("SITE-01", round.SiteIdentifier);
Assert.Equal(original, round);
}
}
@@ -23,7 +23,7 @@ public class AuditEnumTests
}
[Fact]
public void AuditKind_HasExactlyFifteenExpectedMembers()
public void AuditKind_HasExactlySixteenExpectedMembers()
{
var expected = new[]
{
@@ -31,6 +31,7 @@ public class AuditEnumTests
"NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure",
"CachedSubmit", "CachedResolve",
"SecuredWriteSubmit", "SecuredWriteApprove", "SecuredWriteReject", "SecuredWriteExecute",
"SecuredWriteExpire",
"ReconciliationAbandoned",
};
var actual = Enum.GetValues(typeof(AuditKind))
@@ -38,7 +39,7 @@ public class AuditEnumTests
.Select(x => x.ToString())
.ToArray();
Assert.Equal(15, actual.Length);
Assert.Equal(16, actual.Length);
Assert.Equal(expected, actual);
}
@@ -109,6 +109,61 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Contains(all, p => p.Id == executedId);
}
[SkippableFact]
public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
Assert.True(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
// CAS: a second caller loses because the row is no longer Pending.
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
await using var readContext = CreateContext();
var row = await new SecuredWriteRepository(readContext).GetAsync(id);
Assert.NotNull(row);
Assert.Equal("Expired", row!.Status);
Assert.NotNull(row.DecidedAtUtc);
Assert.Null(row.VerifierUser); // system decision, no verifier
}
[SkippableFact]
public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
Assert.True(await repo.TryMarkApprovedAsync(id, "ver.bob", null, DateTime.UtcNow));
// Already Approved -> the conditional WHERE Status='Pending' matches no row.
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
}
[SkippableFact]
public async Task CountAsync_FiltersLikeQueryAsync()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
await repo.AddAsync(NewRow(siteId, status: "Pending"));
await repo.AddAsync(NewRow(siteId, status: "Pending"));
Assert.Equal(2, await repo.CountAsync(status: "Pending", siteId: siteId));
Assert.Equal(0, await repo.CountAsync(status: "Executed", siteId: siteId));
}
// --- helpers ------------------------------------------------------------
private ScadaBridgeDbContext CreateContext()
@@ -233,6 +233,41 @@ public class ArtifactDeploymentServiceTests : TestKit
Arg.Any<string>(), "retry-site", Arg.Any<object>(), Arg.Any<CancellationToken>());
}
// ── PLAN-07 Task 4: single-site artifact deployment ──
[Fact]
public async Task DeployToSiteAsync_DeploysOnlyToTargetSite()
{
// Two sites exist; deploying to site 1 must build/send exactly one per-site
// command (for SITE1) and never touch SITE2's per-site data.
var site1 = new Site("Site One", "SITE1") { Id = 1 };
_siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>()).Returns(site1);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToSiteAsync(1, "tester");
Assert.True(result.IsSuccess);
Assert.Single(result.Value.SiteResults);
Assert.Equal("SITE1", result.Value.SiteResults[0].SiteId);
Assert.Single(recorder.Received);
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
await _siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>());
}
[Fact]
public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure()
{
_siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>()).Returns((Site?)null);
var service = CreateService();
var result = await service.DeployToSiteAsync(999, "tester");
Assert.True(result.IsFailure);
Assert.Contains("999", result.Error);
}
private ArtifactDeploymentService CreateService()
{
var comms = new CommunicationService(
@@ -96,6 +96,10 @@ public class AuditEndpointsTests
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
@@ -652,6 +656,10 @@ public class AuditEndpointsTests
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
@@ -825,6 +833,10 @@ public class AuditEndpointsTests
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
public class ConfigSecretScrubberTests
{
[Fact]
public void Scrub_RedactsPasswordFields_AtAnyDepth()
{
var json = """{"Endpoint":"opc.tcp://x","UserIdentity":{"Username":"u","Password":"p"},"CertificatePassword":"cp"}""";
var (scrubbed, had) = ConfigSecretScrubber.Scrub(json);
Assert.True(had);
Assert.DoesNotContain("\"p\"", scrubbed);
Assert.DoesNotContain("\"cp\"", scrubbed);
Assert.Contains(ConfigSecretScrubber.Sentinel, scrubbed);
Assert.Contains("opc.tcp://x", scrubbed); // non-secrets untouched
}
[Fact]
public void Scrub_NoSecrets_ReturnsFalseFlag()
{
var (_, had) = ConfigSecretScrubber.Scrub("""{"Endpoint":"opc.tcp://x"}""");
Assert.False(had);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-json{")]
public void Scrub_NullEmptyOrMalformed_PassesThrough(string? input)
{
var (scrubbed, had) = ConfigSecretScrubber.Scrub(input);
Assert.Equal(input, scrubbed);
Assert.False(had);
}
[Fact]
public void MergeSentinels_RestoresStoredSecrets_KeepsEdits()
{
var stored = """{"Endpoint":"old","UserIdentity":{"Password":"real"}}""";
var incoming = $$$"""{"Endpoint":"new","UserIdentity":{"Password":"{{{ConfigSecretScrubber.Sentinel}}}"}}""";
var merged = ConfigSecretScrubber.MergeSentinels(incoming, stored)!;
Assert.Contains("\"new\"", merged);
Assert.Contains("\"real\"", merged);
Assert.DoesNotContain(ConfigSecretScrubber.Sentinel, merged);
}
}
@@ -0,0 +1,188 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.CompilerServices;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// arch-review UA2: dispatch-coverage guard. Every command type registered in
/// <see cref="ManagementCommandRegistry"/> must be reachable by a real dispatch arm
/// in <see cref="ManagementActor"/>. A command with no arm falls through to the
/// registry's default case, which throws <see cref="NotSupportedException"/>
/// ("Unknown command type"). This test proves that signature never occurs for any
/// registered command (bar the documented exclusion list), so a newly-added command
/// that someone forgot to wire up fails CI here rather than at runtime.
/// </summary>
/// <remarks>
/// Mechanism: for every registered command type we send a
/// <see cref="RuntimeHelpers.GetUninitializedObject"/> instance (all fields default —
/// we never execute the handler for real) inside an envelope carrying ALL roles, so
/// the authorization gate always passes. The actor runs on a service provider whose
/// scoped <see cref="IServiceProvider.GetService"/> throws a sentinel exception, so
/// any handler that touches the provider faults with the sentinel (a pass — the arm
/// exists). Only a command with NO arm faults with <see cref="NotSupportedException"/>.
/// A recording logger captures the exception <see cref="ManagementActor"/> maps in
/// its fault path, keyed by correlation id; commands that answer without ever
/// touching the provider (or throw some other fault) simply are not
/// <see cref="NotSupportedException"/> and therefore pass.
/// </remarks>
public class DispatchCoverageTests : TestKit, IDisposable
{
/// <summary>
/// Commands that are registered in <see cref="ManagementCommandRegistry"/> (so they
/// carry a stable wire name across the management boundary) but are intentionally
/// NOT dispatched by <see cref="ManagementActor"/> because they are serviced by a
/// different actor path. Each entry is a deliberate design decision, not a missing arm.
/// </summary>
private static readonly HashSet<Type> Excluded = new()
{
// The two-step "ResolveRoles + command" flow is retired — the HTTP endpoint
// resolves roles itself and sends a single envelope. Leaving a handler would
// expose role-mapping data to a raw ClusterClient sender with no role gate.
// See ManagementActor.DispatchCommand (the comment above the default arm, ~line 472).
typeof(ResolveRolesCommand),
// Site-routed, not a central ManagementActor command. Backs the CentralUI
// "Test Bindings" dialog, which sends it straight through
// CommunicationService.ReadTagValuesAsync (in-process) to the site's
// DataConnectionManagerActor / DataConnectionActor — never through the
// ManagementActor dispatch switch. It lives in the Messages.Management
// namespace (hence auto-registered) only to earn a stable wire name for the
// cross-cluster boundary; see ReadTagValuesCommandRegistryTests. A
// ManagementActor arm would be the wrong transport for this command.
typeof(ReadTagValuesCommand),
};
private static IEnumerable<Type> RegisteredCommandTypes() =>
typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
&& !t.IsAbstract);
void IDisposable.Dispose() => Shutdown();
[Fact]
public void EveryRegisteredCommand_ReachesADispatchArm()
{
var logger = new RecordingLogger<ManagementActor>();
var actor = CreateSentinelActor(logger);
var commandTypes = RegisteredCommandTypes()
.Where(t => !Excluded.Contains(t))
.OrderBy(t => t.Name, StringComparer.Ordinal)
.ToList();
// Sanity: the exclusion list must reference real, registered command types.
foreach (var excluded in Excluded)
{
Assert.Contains(excluded, RegisteredCommandTypes());
}
var noDispatchArm = new List<string>();
foreach (var type in commandTypes)
{
var command = RuntimeHelpers.GetUninitializedObject(type);
var correlationId = Guid.NewGuid().ToString("N");
var envelope = new ManagementEnvelope(
new AuthenticatedUser("dispatch-probe", "Dispatch Probe", Roles.All, Array.Empty<string>()),
command, correlationId);
actor.Tell(envelope);
// Barrier: every command produces exactly one response (Success / Error /
// Unauthorized). By the time it arrives, MapFault has already logged any
// fault, so the recording logger is populated for this correlation id.
ExpectMsg<object>(TimeSpan.FromSeconds(10));
if (logger.Captured.TryGetValue(correlationId, out var ex) && ex is NotSupportedException)
{
noDispatchArm.Add(type.Name);
}
}
Assert.True(
noDispatchArm.Count == 0,
$"{noDispatchArm.Count} registered command(s) have no dispatch arm in ManagementActor " +
$"(fell through to the NotSupportedException default). Add the missing arm, or — with " +
$"justification — an entry to the exclusion list: {string.Join(", ", noDispatchArm)}");
}
/// <summary>
/// Builds a <see cref="ManagementActor"/> over a service provider whose scoped
/// <c>GetService</c> always throws <see cref="SentinelException"/>. The root
/// provider still resolves <see cref="IServiceScopeFactory"/> so the actor's
/// <c>CreateScope()</c> call succeeds and the fault surfaces only when a handler
/// actually resolves a service.
/// </summary>
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger)
{
var scopedProvider = Substitute.For<IServiceProvider>();
scopedProvider.GetService(Arg.Any<Type>()).Throws(_ => new SentinelException());
var scope = Substitute.For<IServiceScope>();
scope.ServiceProvider.Returns(scopedProvider);
var scopeFactory = Substitute.For<IServiceScopeFactory>();
scopeFactory.CreateScope().Returns(scope);
var rootProvider = Substitute.For<IServiceProvider>();
rootProvider.GetService(typeof(IServiceScopeFactory)).Returns(scopeFactory);
return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger)));
}
/// <summary>Marker fault thrown by the scoped provider — distinct from the
/// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary>
private sealed class SentinelException : Exception
{
public SentinelException() : base("sentinel: scoped provider touched") { }
}
/// <summary>
/// Minimal <see cref="ILogger{TCategoryName}"/> that records the exception passed
/// to each error log, keyed by the <c>CorrelationId</c> structured field that
/// <see cref="ManagementActor"/>'s fault path always includes.
/// </summary>
private sealed class RecordingLogger<T> : ILogger<T>
{
public ConcurrentDictionary<string, Exception> Captured { get; } = new();
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
{
if (exception is null)
return;
var correlationId = ExtractCorrelationId(state);
if (correlationId is not null)
Captured[correlationId] = exception;
}
private static string? ExtractCorrelationId<TState>(TState state)
{
if (state is IEnumerable<KeyValuePair<string, object?>> pairs)
{
foreach (var pair in pairs)
{
if (pair.Key == "CorrelationId")
return pair.Value?.ToString();
}
}
return null;
}
}
}
@@ -2,12 +2,16 @@ using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Communication;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
@@ -25,6 +29,7 @@ public class ManagementActorTests : TestKit, IDisposable
private readonly ITemplateEngineRepository _templateRepo;
private readonly IAuditService _auditService;
private readonly ServiceCollection _services;
private ArtifactDeploymentService _artifactService = null!;
public ManagementActorTests()
{
@@ -52,6 +57,35 @@ public class ManagementActorTests : TestKit, IDisposable
new(new AuthenticatedUser("scopeduser", "Scoped User", roles, permittedSiteIds),
command, Guid.NewGuid().ToString("N"));
/// <summary>
/// Registers a substitutable <see cref="ArtifactDeploymentService"/> into the
/// harness DI so DeployArtifacts tests can verify which deploy path was taken
/// (single-site vs. fleet-wide) without exercising the real cluster comms.
/// The virtual <c>DeployToSiteAsync</c>/<c>DeployToAllSitesAsync</c> members are
/// intercepted; all other members keep their real (unused) bodies.
/// </summary>
private void RegisterArtifactService()
{
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
_artifactService = Substitute.For<ArtifactDeploymentService>(
Substitute.For<ISiteRepository>(),
Substitute.For<IDeploymentManagerRepository>(),
Substitute.For<ITemplateEngineRepository>(),
Substitute.For<IExternalSystemRepository>(),
Substitute.For<INotificationRepository>(),
comms,
Substitute.For<IAuditService>(),
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance);
_services.AddScoped(_ => _artifactService);
}
private static Result<ArtifactDeploymentSummary> DeploySummary() =>
Result<ArtifactDeploymentSummary>.Success(
new ArtifactDeploymentSummary("deploy-1", Array.Empty<SiteArtifactResult>(), 1, 0));
void IDisposable.Dispose()
{
Shutdown();
@@ -86,6 +120,39 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("Administrator", response.Message);
}
// ========================================================================
// ListSecuredWrites role gate (arch-review UA1): read-only, but the
// history table exposes process-sensitive tag values, so it is restricted
// to the two-person Secured Write roles + Administrator (any-of).
// ========================================================================
[Fact]
public void ListSecuredWrites_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData("Operator")]
[InlineData("Verifier")]
[InlineData("Administrator")]
public void ListSecuredWrites_SecuredWriteRoles_Allowed(string role)
{
var securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
securedWriteRepo.QueryAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<PendingSecuredWrite>>(
Array.Empty<PendingSecuredWrite>()));
_services.AddScoped(_ => securedWriteRepo);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), role));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
[Fact]
public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized()
{
@@ -1248,6 +1315,141 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("deploy-2", response.JsonData);
}
// ========================================================================
// DeployArtifacts honors SiteId + site-scope enforcement (arch-review C2)
//
// MgmtDeployArtifactsCommand.SiteId is now honored: a value routes to the
// single-site deploy path; null routes fleet-wide. A site-scoped (non-Admin)
// Deployer may not deploy fleet-wide, and may not target a site outside scope.
// ========================================================================
[Fact]
public void DeployArtifacts_WithSiteId_CallsSingleSiteDeploy()
{
RegisterArtifactService();
_artifactService.DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>())
.Returns(DeploySummary());
var actor = CreateActor();
actor.Tell(Envelope(new MgmtDeployArtifactsCommand(SiteId: 7), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_artifactService.Received(1).DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>());
_artifactService.DidNotReceiveWithAnyArgs().DeployToAllSitesAsync(default!, default);
}
[Fact]
public void DeployArtifacts_FleetWide_CallsAllSitesDeploy()
{
RegisterArtifactService();
_artifactService.DeployToAllSitesAsync("testuser", Arg.Any<CancellationToken>())
.Returns(DeploySummary());
var actor = CreateActor();
actor.Tell(Envelope(new MgmtDeployArtifactsCommand(SiteId: null), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_artifactService.Received(1).DeployToAllSitesAsync("testuser", Arg.Any<CancellationToken>());
_artifactService.DidNotReceiveWithAnyArgs().DeployToSiteAsync(default, default!, default);
}
[Fact]
public void DeployArtifacts_SiteScopedUser_OutOfScopeSite_Unauthorized()
{
RegisterArtifactService();
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: 7), new[] { "3" }, "Deployer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_artifactService.DidNotReceiveWithAnyArgs().DeployToSiteAsync(default, default!, default);
}
[Fact]
public void DeployArtifacts_SiteScopedUser_FleetWide_Unauthorized()
{
RegisterArtifactService();
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: null), new[] { "3" }, "Deployer"));
var resp = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("fleet-wide", resp.Message, StringComparison.OrdinalIgnoreCase);
_artifactService.DidNotReceiveWithAnyArgs().DeployToAllSitesAsync(default!, default);
}
// ========================================================================
// Additive Skip/Take paging on template/instance lists (arch-review P2)
//
// Take = null preserves the historical unlimited behaviour. When set, paging
// applies AFTER any in-memory site-scope filtering, so a scoped user never sees
// an out-of-scope entity surface into their page window.
// ========================================================================
[Fact]
public void ListTemplates_SkipTake_ReturnsRequestedWindow()
{
var templates = Enumerable.Range(1, 30)
.Select(i => new Template($"T-{i:D3}") { Id = i })
.ToList();
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()).Returns(templates);
var actor = CreateActor();
actor.Tell(Envelope(new ListTemplatesCommand(Skip: 10, Take: 5)));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// Items 11..15 (1-based) present; the pages on either side absent.
Assert.Contains("T-011", response.JsonData);
Assert.Contains("T-015", response.JsonData);
Assert.DoesNotContain("T-010", response.JsonData);
Assert.DoesNotContain("T-016", response.JsonData);
}
[Fact]
public void ListTemplates_DefaultTake_ReturnsAll()
{
var templates = Enumerable.Range(1, 30)
.Select(i => new Template($"T-{i:D3}") { Id = i })
.ToList();
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()).Returns(templates);
var actor = CreateActor();
actor.Tell(Envelope(new ListTemplatesCommand())); // Take = null => unlimited
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("T-001", response.JsonData);
Assert.Contains("T-030", response.JsonData);
}
[Fact]
public void ListInstances_PagingAppliesAfterSiteScopeFilter()
{
// Scoped user permitted to site 1 only. The repo returns 3 in-scope (site 1)
// instances plus 2 out-of-scope (site 2). Take:2 must yield 2 of THEIR
// instances and never an out-of-scope one.
var uiRepo = Substitute.For<ICentralUiRepository>();
uiRepo.GetInstancesFilteredAsync(Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("I-001") { Id = 1, SiteId = 1 },
new("I-002") { Id = 2, SiteId = 1 },
new("I-003") { Id = 3, SiteId = 1 },
new("I-101") { Id = 101, SiteId = 2 },
new("I-102") { Id = 102, SiteId = 2 },
});
_services.AddScoped(_ => uiRepo);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListInstancesCommand(Take: 2), new[] { "1" }, "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("I-001", response.JsonData);
Assert.Contains("I-002", response.JsonData);
Assert.DoesNotContain("I-003", response.JsonData); // paged out
Assert.DoesNotContain("I-101", response.JsonData); // out of scope
Assert.DoesNotContain("I-102", response.JsonData); // out of scope
}
// ========================================================================
// SetInstanceOverrides atomicity (finding -015)
//
@@ -3091,4 +3293,277 @@ public class ManagementActorTests : TestKit, IDisposable
Arg.Is<Commons.Entities.ExternalSystems.ExternalSystemDefinition>(d => d.TimeoutSeconds == 42),
Arg.Any<CancellationToken>());
}
// ========================================================================
// Area management role gate (arch-review C6): reconciled to any-of
// [Designer, Deployer]. The Central UI creates areas inside the Deployment
// Topology workflow (RequireDeployment), so a pure-Deployer must be allowed.
// ========================================================================
[Theory]
[InlineData("Designer")]
[InlineData("Deployer")]
public void CreateArea_DesignerOrDeployer_Allowed(string role)
{
var actor = CreateActor();
actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), role));
// The gate must let both roles through. Dispatch may still fail (no
// AreaService wired), but it must NOT be an authorization rejection.
var resp = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(resp);
}
[Fact]
public void CreateArea_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
// ========================================================================
// Remote-query actor dispatch for the OPC UA design-time commands
// (arch-review C4): BrowseNode / SearchAddressSpace / VerifyEndpoint gain
// actor handlers so the management API/CLI reaches parity with the UI.
// SiteIdentifier is REQUIRED (the actor must route to a site); Designer-gated.
// ========================================================================
[Fact]
public void BrowseNode_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null), "Designer"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void BrowseNode_Designer_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.BrowseCount);
Assert.Equal("SITE1", stub.LastBrowseSite);
}
[Fact]
public void BrowseNode_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void SearchAddressSpace_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50), "Designer"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void SearchAddressSpace_Designer_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.SearchCount);
Assert.Equal("SITE1", stub.LastSearchSite);
}
[Fact]
public void SearchAddressSpace_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void VerifyEndpoint_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}"), "Designer"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void VerifyEndpoint_Designer_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.VerifyCount);
Assert.Equal("SITE1", stub.LastVerifySite);
}
[Fact]
public void VerifyEndpoint_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
// ========================================================================
// Admin-gated actor dispatch for the site cert-trust commands
// (arch-review C4): TrustServerCert / RemoveServerCert / ListServerCerts.
// These mutate the site's trusted-peer PKI store, so they are gated
// Administrator — matching the Admin-gated cert-management UI. SiteIdentifier
// is REQUIRED; the handler only relays (site-side reconcile is CertStoreActor).
// ========================================================================
[Fact]
public void TrustServerCert_DesignerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Designer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void TrustServerCert_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12"), "Administrator"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void TrustServerCert_Admin_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.TrustCount);
Assert.Equal("SITE1", stub.LastTrustSite);
}
[Fact]
public void RemoveServerCert_Admin_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new RemoveServerCertCommand("AB12", "SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.RemoveCount);
Assert.Equal("SITE1", stub.LastRemoveSite);
}
[Fact]
public void RemoveServerCert_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new RemoveServerCertCommand("AB12"), "Administrator"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void ListServerCerts_Admin_RelaysToSite()
{
var stub = new StubCommunicationService();
_services.AddSingleton<CommunicationService>(stub);
var actor = CreateActor();
actor.Tell(Envelope(new ListServerCertsCommand("SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(1, stub.ListCertsCount);
Assert.Equal("SITE1", stub.LastListCertsSite);
}
[Fact]
public void ListServerCerts_DesignerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new ListServerCertsCommand("SITE1"), "Designer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>
/// Records remote-query relays for the actor-dispatch tests. The browse /
/// search / verify / cert-trust methods on <see cref="CommunicationService"/>
/// are virtual so the relay can be exercised without a live actor system.
/// </summary>
private sealed class StubCommunicationService : CommunicationService
{
public StubCommunicationService()
: base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
{
}
public int BrowseCount { get; private set; }
public string? LastBrowseSite { get; private set; }
public int SearchCount { get; private set; }
public string? LastSearchSite { get; private set; }
public int VerifyCount { get; private set; }
public string? LastVerifySite { get; private set; }
public int TrustCount { get; private set; }
public string? LastTrustSite { get; private set; }
public int RemoveCount { get; private set; }
public string? LastRemoveSite { get; private set; }
public int ListCertsCount { get; private set; }
public string? LastListCertsSite { get; private set; }
public override Task<CertTrustResult> TrustServerCertAsync(
string siteId, TrustServerCertCommand command, CancellationToken ct = default)
{
TrustCount++;
LastTrustSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
public override Task<CertTrustResult> RemoveServerCertAsync(
string siteId, RemoveServerCertCommand command, CancellationToken ct = default)
{
RemoveCount++;
LastRemoveSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
public override Task<CertTrustResult> ListServerCertsAsync(
string siteId, ListServerCertsCommand command, CancellationToken ct = default)
{
ListCertsCount++;
LastListCertsSite = siteId;
return Task.FromResult(new CertTrustResult(true, null, null));
}
public override Task<BrowseNodeResult> BrowseNodeAsync(
string siteId, BrowseNodeCommand command, CancellationToken ct = default)
{
BrowseCount++;
LastBrowseSite = siteId;
return Task.FromResult(new BrowseNodeResult(Array.Empty<BrowseNode>(), false, null));
}
public override Task<SearchAddressSpaceResult> SearchAddressSpaceAsync(
string siteId, SearchAddressSpaceCommand command, CancellationToken ct = default)
{
SearchCount++;
LastSearchSite = siteId;
return Task.FromResult(new SearchAddressSpaceResult(Array.Empty<AddressSpaceMatch>(), false, null));
}
public override Task<VerifyEndpointResult> VerifyEndpointAsync(
string siteId, VerifyEndpointCommand command, CancellationToken ct = default)
{
VerifyCount++;
LastVerifySite = siteId;
return Task.FromResult(new VerifyEndpointResult(true, null, null, null));
}
}
}
@@ -1,6 +1,9 @@
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
@@ -93,4 +96,81 @@ public class ManagementAuthenticatorTests
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
}
// --- Login-throttle wiring (arch-review P1/UA5) -------------------------------------------
/// <summary>
/// Builds a real Basic-Auth request context wired with a <see cref="LoginThrottle"/>
/// (over the injected <paramref name="clock"/>) and the supplied LDAP mock, with the given
/// remote IP. Returns the context plus the resolvable throttle so tests can seed failures.
/// </summary>
private static (HttpContext Context, LoginThrottle Throttle) BasicAuthContext(
string username,
string password,
ILdapAuthService ldap,
TimeProvider clock,
string remoteIp = "127.0.0.1")
{
var services = new ServiceCollection();
services.AddSingleton<IOptions<AuthDisableLoginOptions>>(
Options.Create(new AuthDisableLoginOptions { DisableLogin = false }));
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton(clock);
services.AddSingleton<LoginThrottle>();
services.AddSingleton(ldap);
services.AddLogging(); // JsonHttpResult.ExecuteAsync resolves ILoggerFactory
var provider = services.BuildServiceProvider();
var context = new DefaultHttpContext { RequestServices = provider };
var creds = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
context.Request.Headers.Authorization = $"Basic {creds}";
context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse(remoteIp);
context.Response.Body = new MemoryStream();
return (context, provider.GetRequiredService<LoginThrottle>());
}
private static async Task<(int Status, string Body)> ExecuteAsync(HttpContext context, IResult result)
{
await result.ExecuteAsync(context);
context.Response.Body.Position = 0;
using var reader = new StreamReader(context.Response.Body);
var body = await reader.ReadToEndAsync();
return (context.Response.StatusCode, body);
}
[Fact]
public async Task AuthenticateAsync_LockedOutKey_Returns429WithoutLdapBind()
{
var ldap = Substitute.For<ILdapAuthService>();
var (context, throttle) = BasicAuthContext("alice", "wrong", ldap, TimeProvider.System);
for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "127.0.0.1");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
var (status, body) = await ExecuteAsync(context, outcome.Failure!);
Assert.Equal(429, status);
Assert.Contains("AUTH_THROTTLED", body);
await ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default);
}
[Fact]
public async Task AuthenticateAsync_FailedBind_RecordsFailure()
{
var ldap = Substitute.For<ILdapAuthService>();
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
var (context, throttle) = BasicAuthContext("alice", "wrong", ldap, TimeProvider.System);
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
// The bind failed → a 401 is returned AND the failure is recorded on the throttle.
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
Assert.False(throttle.IsLockedOut("alice", "127.0.0.1"));
for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "127.0.0.1");
Assert.True(throttle.IsLockedOut("alice", "127.0.0.1"));
}
}
@@ -99,4 +99,30 @@ public class ManagementEndpointsTests
Assert.Equal(TimeSpan.FromSeconds(30), timeout);
}
// ========================================================================
// Per-command long-running Ask timeout (arch-review S1)
//
// Transport/deploy commands legitimately run for minutes; they must use the
// LongRunningCommandTimeout (default 5 min) instead of the 30 s default.
// ========================================================================
[Theory]
[InlineData(typeof(ImportBundleCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
[InlineData(typeof(MgmtDeployArtifactsCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
public void ResolveAskTimeout_LongRunningCommand_UsesLongTimeout(Type cmd)
=> Assert.Equal(TimeSpan.FromMinutes(5), ManagementEndpoints.ResolveAskTimeout(null, cmd));
[Fact]
public void ResolveAskTimeout_OrdinaryCommand_UsesDefault()
=> Assert.Equal(TimeSpan.FromSeconds(30), ManagementEndpoints.ResolveAskTimeout(null, typeof(ListTemplatesCommand)));
[Fact]
public void ResolveAskTimeout_ConfiguredLongTimeout_Honored()
=> Assert.Equal(TimeSpan.FromMinutes(10), ManagementEndpoints.ResolveAskTimeout(
new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(10) },
typeof(ImportBundleCommand)));
}
@@ -0,0 +1,208 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Frozen authorization matrix (arch-review UA2). Every registered management command
/// MUST have an entry in <see cref="Expected"/> giving its required-roles set (any-of
/// semantics) or <c>null</c> when the command is available to any authenticated user.
/// A newly-added command with no entry fails <see cref="EveryRegisteredCommand_IsInTheFrozenTable"/>,
/// forcing a deliberate authorization decision instead of silently defaulting to the
/// read-only (any-authenticated) gate — the class of gap that produced C2/C4.
///
/// The table reflects ACTUAL current behavior of
/// <c>ManagementActor.GetRequiredRoles</c>; it is authored by hand from the switch and
/// cross-checked against <c>docs/requirements/Component-ManagementService.md</c>
/// §Authorization. Do not regenerate it mechanically from the source under test — that
/// would defeat the freeze.
/// </summary>
public class RequiredRoleMatrixTests
{
private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];
private static readonly string[] SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator];
private static readonly Dictionary<string, string[]?> Expected = new(StringComparer.OrdinalIgnoreCase)
{
// ---- Administrator-only -----------------------------------------------------
["CreateSite"] = [Roles.Administrator],
["UpdateSite"] = [Roles.Administrator],
["DeleteSite"] = [Roles.Administrator],
["ListRoleMappings"] = [Roles.Administrator],
["CreateRoleMapping"] = [Roles.Administrator],
["UpdateRoleMapping"] = [Roles.Administrator],
["DeleteRoleMapping"] = [Roles.Administrator],
["ListApiKeys"] = [Roles.Administrator],
["CreateApiKey"] = [Roles.Administrator],
["DeleteApiKey"] = [Roles.Administrator],
["UpdateApiKey"] = [Roles.Administrator],
["SetApiKeyMethods"] = [Roles.Administrator],
["ListScopeRules"] = [Roles.Administrator],
["AddScopeRule"] = [Roles.Administrator],
["DeleteScopeRule"] = [Roles.Administrator],
["QueryAuditLog"] = [Roles.Administrator],
["UpdateSmtpConfig"] = [Roles.Administrator],
["UpdateSmsConfig"] = [Roles.Administrator],
["TrustServerCert"] = [Roles.Administrator],
["RemoveServerCert"] = [Roles.Administrator],
["ListServerCerts"] = [Roles.Administrator],
// Transport inbound bundle handling mutates cross-cutting config → Admin.
["PreviewBundle"] = [Roles.Administrator],
["ImportBundle"] = [Roles.Administrator],
// ---- Area management: any-of [Designer, Deployer] ---------------------------
["CreateArea"] = AreaManagers,
["UpdateArea"] = AreaManagers,
["DeleteArea"] = AreaManagers,
// ---- Designer-only ----------------------------------------------------------
["CreateTemplate"] = [Roles.Designer],
["UpdateTemplate"] = [Roles.Designer],
["DeleteTemplate"] = [Roles.Designer],
["ValidateTemplate"] = [Roles.Designer],
["CreateExternalSystem"] = [Roles.Designer],
["UpdateExternalSystem"] = [Roles.Designer],
["DeleteExternalSystem"] = [Roles.Designer],
["CreateExternalSystemMethod"] = [Roles.Designer],
["UpdateExternalSystemMethod"] = [Roles.Designer],
["DeleteExternalSystemMethod"] = [Roles.Designer],
["CreateNotificationList"] = [Roles.Designer],
["UpdateNotificationList"] = [Roles.Designer],
["DeleteNotificationList"] = [Roles.Designer],
["CreateDataConnection"] = [Roles.Designer],
["UpdateDataConnection"] = [Roles.Designer],
["DeleteDataConnection"] = [Roles.Designer],
["MoveDataConnection"] = [Roles.Designer],
["AddTemplateAttribute"] = [Roles.Designer],
["UpdateTemplateAttribute"] = [Roles.Designer],
["DeleteTemplateAttribute"] = [Roles.Designer],
["AddTemplateAlarm"] = [Roles.Designer],
["UpdateTemplateAlarm"] = [Roles.Designer],
["DeleteTemplateAlarm"] = [Roles.Designer],
["AddTemplateNativeAlarmSource"] = [Roles.Designer],
["UpdateTemplateNativeAlarmSource"] = [Roles.Designer],
["DeleteTemplateNativeAlarmSource"] = [Roles.Designer],
["AddTemplateScript"] = [Roles.Designer],
["UpdateTemplateScript"] = [Roles.Designer],
["DeleteTemplateScript"] = [Roles.Designer],
["AddTemplateComposition"] = [Roles.Designer],
["DeleteTemplateComposition"] = [Roles.Designer],
["ResyncInheritedMembers"] = [Roles.Designer],
["CreateSharedScript"] = [Roles.Designer],
["UpdateSharedScript"] = [Roles.Designer],
["DeleteSharedScript"] = [Roles.Designer],
["CreateSharedSchema"] = [Roles.Designer],
["UpdateSharedSchema"] = [Roles.Designer],
["DeleteSharedSchema"] = [Roles.Designer],
["CreateDatabaseConnectionDef"] = [Roles.Designer],
["UpdateDatabaseConnectionDef"] = [Roles.Designer],
["DeleteDatabaseConnectionDef"] = [Roles.Designer],
["CreateApiMethod"] = [Roles.Designer],
["UpdateApiMethod"] = [Roles.Designer],
["DeleteApiMethod"] = [Roles.Designer],
["CreateTemplateFolder"] = [Roles.Designer],
["RenameTemplateFolder"] = [Roles.Designer],
["MoveTemplateFolder"] = [Roles.Designer],
["ReorderTemplateFolder"] = [Roles.Designer],
["DeleteTemplateFolder"] = [Roles.Designer],
["MoveTemplateToFolder"] = [Roles.Designer],
["BrowseNode"] = [Roles.Designer],
["SearchAddressSpace"] = [Roles.Designer],
["VerifyEndpoint"] = [Roles.Designer],
["ExportBundle"] = [Roles.Designer],
// ---- Deployer-only ----------------------------------------------------------
["CreateInstance"] = [Roles.Deployer],
["MgmtDeployInstance"] = [Roles.Deployer],
["MgmtEnableInstance"] = [Roles.Deployer],
["MgmtDisableInstance"] = [Roles.Deployer],
["MgmtDeleteInstance"] = [Roles.Deployer],
["SetConnectionBindings"] = [Roles.Deployer],
["SetInstanceOverrides"] = [Roles.Deployer],
["SetInstanceArea"] = [Roles.Deployer],
["SetInstanceAlarmOverride"] = [Roles.Deployer],
["DeleteInstanceAlarmOverride"] = [Roles.Deployer],
["SetInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
["DeleteInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
["GetDeploymentDiff"] = [Roles.Deployer],
["MgmtDeployArtifacts"] = [Roles.Deployer],
["QueryDeployments"] = [Roles.Deployer],
["RetryParkedMessage"] = [Roles.Deployer],
["DiscardParkedMessage"] = [Roles.Deployer],
["DebugSnapshot"] = [Roles.Deployer],
// ---- Two-person secured write ----------------------------------------------
["SubmitSecuredWrite"] = [Roles.Operator],
["ApproveSecuredWrite"] = [Roles.Verifier],
["RejectSecuredWrite"] = [Roles.Verifier],
["ListSecuredWrites"] = SecuredWriteReaders,
// ---- Read-only: any authenticated user (null gate) -------------------------
["ListTemplates"] = null,
["GetTemplate"] = null,
["GetResolvedTemplateMembers"] = null,
["ListTemplateFolders"] = null,
["ListTemplateNativeAlarmSources"] = null,
["ListInstances"] = null,
["GetInstance"] = null,
["ListInstanceAlarmOverrides"] = null,
["ListInstanceNativeAlarmSourceOverrides"] = null,
["ListSites"] = null,
["GetSite"] = null,
["ListAreas"] = null,
["ListDataConnections"] = null,
["GetDataConnection"] = null,
["ListExternalSystems"] = null,
["GetExternalSystem"] = null,
["ListExternalSystemMethods"] = null,
["GetExternalSystemMethod"] = null,
["ListNotificationLists"] = null,
["GetNotificationList"] = null,
["ListSmtpConfigs"] = null,
["ListSmsConfigs"] = null,
["ListSharedScripts"] = null,
["GetSharedScript"] = null,
["ListSharedSchemas"] = null,
["GetSharedSchema"] = null,
["ListDatabaseConnections"] = null,
["GetDatabaseConnection"] = null,
["ListApiMethods"] = null,
["GetApiMethod"] = null,
["GetHealthSummary"] = null,
["GetSiteHealth"] = null,
["QueryEventLogs"] = null,
["QueryParkedMessages"] = null,
["ReadTagValues"] = null,
// ResolveRoles is intentionally not dispatched (retired two-step flow); it
// falls through to the default null gate. Present here so the frozen table
// still covers the type reflected in the assembly.
["ResolveRoles"] = null,
};
public static IEnumerable<object[]> AllRegisteredCommands()
=> typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
&& t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract)
.Select(t => new object[] { t });
[Theory, MemberData(nameof(AllRegisteredCommands))]
public void EveryRegisteredCommand_IsInTheFrozenTable(Type commandType)
{
var name = commandType.Name[..^"Command".Length];
Assert.True(Expected.ContainsKey(name),
$"Command '{name}' has no entry in the frozen authorization table — add one deliberately.");
var instance = RuntimeHelpers.GetUninitializedObject(commandType);
var actual = ManagementActor.GetRequiredRoles(instance);
var expected = Expected[name];
if (expected is null)
{
Assert.Null(actual);
}
else
{
Assert.NotNull(actual);
Assert.Equal(expected.OrderBy(x => x), actual!.OrderBy(x => x));
}
}
}
@@ -109,9 +109,47 @@ public class RoleMappingValidationTests : TestKit, IDisposable
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(inserted);
// Membership check does not alter the stored value's casing (case-sensitivity
// asymmetry is a separately-deferred change).
// A correctly-cased canonical role round-trips unchanged.
Assert.Equal(Roles.Deployer, inserted!.Role);
Assert.Equal("SCADA-Deploy", inserted.LdapGroupName);
}
[Fact]
public void CreateRoleMapping_LowercaseRole_StoresCanonicalCasing()
{
// arch-review C5: ASP.NET RequireClaim policies compare Role ordinal
// case-sensitively while the actor compares OrdinalIgnoreCase. A row stored
// as "deployer" would pass every actor gate but fail every UI policy (a
// CLI-works/UI-403s split-brain). The write path now canonicalizes to the
// Roles.All casing, so the stored value is always "Deployer".
LdapGroupMapping? inserted = null;
_securityRepo
.When(r => r.AddMappingAsync(Arg.Any<LdapGroupMapping>(), Arg.Any<CancellationToken>()))
.Do(ci => inserted = ci.Arg<LdapGroupMapping>());
var actor = CreateActor();
actor.Tell(Envelope(new CreateRoleMappingCommand("SCADA-Deploy", "deployer")));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(inserted);
Assert.Equal("Deployer", inserted!.Role); // canonical, not the verbatim "deployer"
}
[Fact]
public void UpdateRoleMapping_LowercaseRole_StoresCanonicalCasing()
{
var existing = new LdapGroupMapping("SCADA-Deploy", Roles.Viewer) { Id = 7 };
_securityRepo.GetMappingByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
LdapGroupMapping? updated = null;
_securityRepo
.When(r => r.UpdateMappingAsync(Arg.Any<LdapGroupMapping>(), Arg.Any<CancellationToken>()))
.Do(ci => updated = ci.Arg<LdapGroupMapping>());
var actor = CreateActor();
actor.Tell(Envelope(new UpdateRoleMappingCommand(7, "SCADA-Deploy", "deployer")));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(updated);
Assert.Equal("Deployer", updated!.Role);
}
}
@@ -0,0 +1,276 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Secret-elision projection tests (arch-review C3): connection-config secrets
/// (OPC UA passwords, external-system auth config, DB connection strings) must
/// never leave the management boundary via List/Get responses or audit afterState,
/// and sentinel/omitted round-trips through Update must preserve the stored secret.
/// </summary>
public class SecretProjectionTests : TestKit, IDisposable
{
private readonly IAuditService _auditService;
private readonly ServiceCollection _services;
public SecretProjectionTests()
{
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection();
_services.AddScoped(_ => _auditService);
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(
sp, NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
// ========================================================================
// Task 7 — DataConnection config secret elision
// ========================================================================
private ISiteRepository AddSiteRepoWithConnection(DataConnection conn)
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(conn.Id, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { conn });
_services.AddScoped(_ => siteRepo);
return siteRepo;
}
[Fact]
public void GetDataConnection_ResponseOmitsPasswords()
{
AddSiteRepoWithConnection(new DataConnection("c1", "OpcUa", 1)
{
Id = 1,
PrimaryConfiguration = """{"Endpoint":"opc.tcp://x","UserIdentity":{"Password":"opc-secret"}}""",
});
var actor = CreateActor();
actor.Tell(Envelope(new GetDataConnectionCommand(1), "Viewer"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("opc-secret", resp.JsonData);
Assert.Contains("hasSecrets", resp.JsonData); // camelCase serializer
}
[Fact]
public void ListDataConnections_ResponseOmitsPasswords()
{
AddSiteRepoWithConnection(new DataConnection("c1", "OpcUa", 1)
{
Id = 1,
PrimaryConfiguration = """{"UserIdentity":{"Password":"opc-secret"}}""",
});
var actor = CreateActor();
actor.Tell(Envelope(new ListDataConnectionsCommand(null), "Viewer"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("opc-secret", resp.JsonData);
Assert.Contains("hasSecrets", resp.JsonData);
}
[Fact]
public async Task UpdateDataConnection_SentinelConfig_PreservesStoredSecret()
{
var stored = new DataConnection("c1", "OpcUa", 1)
{
Id = 1,
PrimaryConfiguration = """{"Endpoint":"opc.tcp://old","UserIdentity":{"Password":"opc-secret"}}""",
};
var siteRepo = AddSiteRepoWithConnection(stored);
var actor = CreateActor();
var incoming = """{"Endpoint":"opc.tcp://new","UserIdentity":{"Password":"***REDACTED***"}}""";
actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", incoming, null, 3), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
await siteRepo.Received(1).UpdateDataConnectionAsync(
Arg.Is<DataConnection>(c => c.PrimaryConfiguration!.Contains("opc-secret")
&& !c.PrimaryConfiguration.Contains("***REDACTED***")),
Arg.Any<CancellationToken>());
}
[Fact]
public void UpdateDataConnection_AuditAfterState_OmitsPasswords()
{
var stored = new DataConnection("c1", "OpcUa", 1)
{
Id = 1,
PrimaryConfiguration = """{"Password":"opc-secret"}""",
};
AddSiteRepoWithConnection(stored);
var actor = CreateActor();
actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", """{"Password":"newpw"}""", null, 3), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_auditService.Received().LogAsync(Arg.Any<string>(), "Update", "DataConnection",
Arg.Any<string>(), Arg.Any<string>(),
Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newpw")),
Arg.Any<CancellationToken>());
}
// ========================================================================
// Task 8 — ExternalSystem AuthConfiguration elision
// ========================================================================
private IExternalSystemRepository AddExternalSystemRepo(ExternalSystemDefinition def)
{
var repo = Substitute.For<IExternalSystemRepository>();
repo.GetExternalSystemByIdAsync(def.Id, Arg.Any<CancellationToken>()).Returns(def);
repo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { def });
_services.AddScoped(_ => repo);
return repo;
}
[Fact]
public void GetExternalSystem_ResponseOmitsAuthConfiguration()
{
AddExternalSystemRepo(new ExternalSystemDefinition("es", "https://x", "ApiKey")
{
Id = 1,
AuthConfiguration = """{"apiKey":"es-secret"}""",
});
var actor = CreateActor();
actor.Tell(Envelope(new GetExternalSystemCommand(1), "Viewer"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("es-secret", resp.JsonData);
Assert.Contains("hasAuthConfiguration", resp.JsonData);
}
[Fact]
public void ListExternalSystems_ResponseOmitsAuthConfiguration()
{
AddExternalSystemRepo(new ExternalSystemDefinition("es", "https://x", "ApiKey")
{
Id = 1,
AuthConfiguration = """{"apiKey":"es-secret"}""",
});
var actor = CreateActor();
actor.Tell(Envelope(new ListExternalSystemsCommand(), "Viewer"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("es-secret", resp.JsonData);
Assert.Contains("hasAuthConfiguration", resp.JsonData);
}
[Fact]
public async Task UpdateExternalSystem_NullAuthConfiguration_PreservesStoredSecret()
{
var stored = new ExternalSystemDefinition("es", "https://x", "ApiKey")
{
Id = 1,
AuthConfiguration = """{"apiKey":"es-secret"}""",
};
var repo = AddExternalSystemRepo(stored);
var actor = CreateActor();
actor.Tell(Envelope(new UpdateExternalSystemCommand(1, "es", "https://y", "ApiKey", null), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
await repo.Received(1).UpdateExternalSystemAsync(
Arg.Is<ExternalSystemDefinition>(d => d.AuthConfiguration == """{"apiKey":"es-secret"}"""),
Arg.Any<CancellationToken>());
}
[Fact]
public void UpdateExternalSystem_AuditAfterState_OmitsAuthConfiguration()
{
var stored = new ExternalSystemDefinition("es", "https://x", "ApiKey")
{
Id = 1,
AuthConfiguration = """{"apiKey":"es-secret"}""",
};
AddExternalSystemRepo(stored);
var actor = CreateActor();
actor.Tell(Envelope(new UpdateExternalSystemCommand(1, "es", "https://y", "ApiKey", """{"apiKey":"newsecret"}"""), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_auditService.Received().LogAsync(Arg.Any<string>(), "Update", "ExternalSystem",
Arg.Any<string>(), Arg.Any<string>(),
Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newsecret")),
Arg.Any<CancellationToken>());
}
// ========================================================================
// Task 9 — DatabaseConnection ConnectionString elision
// ========================================================================
private IExternalSystemRepository AddDatabaseConnectionRepo(DatabaseConnectionDefinition def)
{
var repo = Substitute.For<IExternalSystemRepository>();
repo.GetDatabaseConnectionByIdAsync(def.Id, Arg.Any<CancellationToken>()).Returns(def);
repo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<DatabaseConnectionDefinition> { def });
_services.AddScoped(_ => repo);
return repo;
}
[Fact]
public void GetDatabaseConnection_ResponseOmitsConnectionString()
{
AddDatabaseConnectionRepo(new DatabaseConnectionDefinition("db", "Server=db;Password=sql-secret") { Id = 1 });
var actor = CreateActor();
actor.Tell(Envelope(new GetDatabaseConnectionCommand(1), "Viewer"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("sql-secret", resp.JsonData);
Assert.Contains("hasConnectionString", resp.JsonData);
}
[Fact]
public void ListDatabaseConnections_ResponseOmitsConnectionString()
{
AddDatabaseConnectionRepo(new DatabaseConnectionDefinition("db", "Server=db;Password=sql-secret") { Id = 1 });
var actor = CreateActor();
actor.Tell(Envelope(new ListDatabaseConnectionsCommand(), "Viewer"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("sql-secret", resp.JsonData);
Assert.Contains("hasConnectionString", resp.JsonData);
}
[Fact]
public async Task UpdateDatabaseConnection_NullConnectionString_PreservesStoredSecret()
{
var stored = new DatabaseConnectionDefinition("db", "Server=db;Password=sql-secret") { Id = 1 };
var repo = AddDatabaseConnectionRepo(stored);
var actor = CreateActor();
actor.Tell(Envelope(new UpdateDatabaseConnectionDefCommand(1, "db-renamed", null), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
await repo.Received(1).UpdateDatabaseConnectionAsync(
Arg.Is<DatabaseConnectionDefinition>(d => d.ConnectionString == "Server=db;Password=sql-secret"),
Arg.Any<CancellationToken>());
}
}
@@ -53,6 +53,12 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
_services.AddScoped(_ => _auditRepo);
_services.AddSingleton<CommunicationService>(_comms);
// Server-side secured-write TTL (arch-review S2): the expiry helper reads
// SecuredWritePendingTtl off IOptions<ManagementServiceOptions>. Register the
// defaults (24 h TTL) so the TTL tests exercise the real threshold.
_services.AddSingleton<IOptions<ManagementServiceOptions>>(
Options.Create(new ManagementServiceOptions()));
// #206: route SecuredWrite audit through the REAL CentralAuditWriter (the same
// central-direct-write path Notification Outbox dispatch + Inbound API use) so
// these tests exercise SourceNode stamping end-to-end — actor → writer → repo.
@@ -352,7 +358,10 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
});
var actor = CreateActor();
var envelope = Envelope(new ListSecuredWritesCommand("Pending", "SITE1"), "carol", "Viewer");
// arch-review UA1: ListSecuredWrites is gated to the Secured Write
// roles + Administrator, so use Operator here (this test asserts filter
// forwarding, not the role gate — see ListSecuredWrites_* auth tests).
var envelope = Envelope(new ListSecuredWritesCommand("Pending", "SITE1"), "carol", "Operator");
actor.Tell(envelope);
@@ -370,7 +379,8 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
var envelope = Envelope(new ListSecuredWritesCommand(null, null), "carol");
// arch-review UA1: gated read — Operator satisfies the any-of gate.
var envelope = Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator");
actor.Tell(envelope);
@@ -603,6 +613,127 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
Assert.Contains("not found", updated.ExecutionError);
}
// ------------------------------------------------------------------------
// List paging — Task 17 (arch-review P3)
// ------------------------------------------------------------------------
[Fact]
public void List_WithSkipTake_ForwardsPagingAndReturnsTotalCount()
{
_securedWriteRepo.QueryAsync(null, null, 10, 25, Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
_securedWriteRepo.CountAsync(null, null, Arg.Any<CancellationToken>()).Returns(87);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null, Skip: 10, Take: 25), "carol", "Operator"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 10, 25, Arg.Any<CancellationToken>());
_securedWriteRepo.Received(1).CountAsync(null, null, Arg.Any<CancellationToken>());
Assert.Contains("\"totalCount\":87", resp.JsonData);
}
// ------------------------------------------------------------------------
// Opportunistic expiry sweep on list — Task 16 (arch-review S2)
// ------------------------------------------------------------------------
[Fact]
public void List_SweepsStalePendingRows_MarksExpired_CallsTryMarkExpiredOnceForStaleOnly()
{
var fresh = new PendingSecuredWrite
{
Id = 1, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow.AddHours(-1)
};
var stale = new PendingSecuredWrite
{
Id = 2, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.B",
ValueJson = "false", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow.AddHours(-25) // TTL default 24h
};
_securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite> { fresh, stale });
_securedWriteRepo.TryMarkExpiredAsync(2, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The stale row surfaces as Expired in the projection; the fresh row stays Pending.
Assert.Contains("\"id\":2", resp.JsonData);
Assert.Contains("Expired", resp.JsonData);
// Exactly one CAS — only the overdue row.
_securedWriteRepo.Received(1).TryMarkExpiredAsync(2, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
_securedWriteRepo.DidNotReceive().TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
// ------------------------------------------------------------------------
// Server-side TTL expiry — Task 15 (arch-review S2)
// ------------------------------------------------------------------------
[Fact]
public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting()
{
var row = SeedPendingWrite(1, operatorUser: "alice");
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-25); // TTL default 24h
_securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
var actor = CreateActor();
actor.Tell(Envelope(new ApproveSecuredWriteCommand(1, null), "bob", "Verifier"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
_securedWriteRepo.DidNotReceive().TryMarkApprovedAsync(
Arg.Any<long>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
// The stale setpoint is NEVER relayed to the site.
Assert.Equal(0, _comms.CallCount);
_securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Reject_PendingRowOlderThanTtl_ExpiresInsteadOfRejecting()
{
var row = SeedPendingWrite(1, operatorUser: "alice");
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-25);
_securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
var actor = CreateActor();
actor.Tell(Envelope(new RejectSecuredWriteCommand(1, null), "bob", "Verifier"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
// The reject path never overwrites the row (the CAS expiry owns the transition).
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
_securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Approve_FreshPendingRow_StillExecutes()
{
// Submitted 1h ago → within the 24h TTL, so the existing approve path is unchanged.
var row = SeedPendingWrite(7, operatorUser: "alice");
row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-1);
ArmCasSuccess(7);
PendingSecuredWrite? updated = null;
_securedWriteRepo
.When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
.Do(ci => updated = ci.Arg<PendingSecuredWrite>());
var actor = CreateActor();
actor.Tell(Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkExpiredAsync(default, default, default);
Assert.Equal(1, _comms.CallCount);
Assert.NotNull(updated);
Assert.Equal("Executed", updated!.Status);
}
// ------------------------------------------------------------------------
// Audit emission (T14b — SecuredWrite AuditLog rows)
// ------------------------------------------------------------------------
@@ -0,0 +1,93 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Verifies the static <c>TransportGate</c> single-flight guard (arch-review S4):
/// transport bundle commands (export/preview/import) queue rather than run
/// concurrently, because each import holds ~4 concurrent whole-artifact copies of
/// the payload and two concurrent large imports on one central node are a
/// realistic OOM path. The gate is exercised through the real import handler so
/// the test tracks the actual concurrency shape of <see cref="IBundleImporter"/>.
/// </summary>
public class TransportGateTests : TestKit, IDisposable
{
private readonly IBundleImporter _importer;
private readonly ServiceCollection _services;
public TransportGateTests()
{
_importer = Substitute.For<IBundleImporter>();
_services = new ServiceCollection();
_services.AddScoped(_ => _importer);
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(
sp, NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command) =>
new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
[Fact]
public async Task ImportBundle_ConcurrentCommands_AreSingleFlighted()
{
// LoadAsync blocks on this TCS so the first import stays inside the gate.
var release = new TaskCompletionSource<BundleSession>(
TaskCreationOptions.RunContinuationsAsynchronously);
var loadStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var loadCalls = 0;
var session = new BundleSession { SessionId = Guid.NewGuid() };
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
Interlocked.Increment(ref loadCalls);
loadStarted.TrySetResult();
return release.Task;
});
_importer.PreviewAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(new ImportPreview(session.SessionId, Array.Empty<ImportPreviewItem>()));
_importer.ApplyAsync(
Arg.Any<Guid>(), Arg.Any<IReadOnlyList<ImportResolution>>(),
Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<BundleNameMap?>())
.Returns(new ImportResult(
Guid.NewGuid(), 0, 0, 0, 0, Array.Empty<int>(), "corr"));
var actor = CreateActor();
// A trivially-valid base64 payload; the substitute importer never parses it.
var bundle = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 });
actor.Tell(Envelope(new ImportBundleCommand(bundle, null, "skip")));
actor.Tell(Envelope(new ImportBundleCommand(bundle, null, "skip")));
// The first import must reach LoadAsync...
await loadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
// ...and the second must be parked at the gate: give it ample time to
// (wrongly) proceed, then assert it did not.
await Task.Delay(750);
Assert.Equal(1, Volatile.Read(ref loadCalls));
// Release the first; the second should now proceed and both should answer.
release.SetResult(session);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
AwaitAssert(() => Assert.Equal(2, Volatile.Read(ref loadCalls)),
TimeSpan.FromSeconds(5));
}
}
@@ -0,0 +1,113 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
/// <summary>
/// Exercises <see cref="LoginThrottle"/>: the fixed-window per-(username+IP)
/// LDAP-bind failure lockout. Time is driven by a hand-rolled controllable
/// <see cref="TimeProvider"/> (the central package list has no
/// Microsoft.Extensions.TimeProvider.Testing FakeTimeProvider — mirroring the
/// CookieSessionValidator + Transport tests, which hand-roll the same clock).
/// </summary>
public class LoginThrottleTests
{
private static readonly DateTimeOffset Start = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset start) => _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
private static LoginThrottle Create(TimeProvider clock, SecurityOptions? options = null) =>
new(clock, Options.Create(options ?? new SecurityOptions()));
[Fact]
public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks()
{
var clock = new TestTimeProvider(Start);
var throttle = Create(clock);
for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
Assert.False(throttle.IsLockedOut("alice", "10.0.0.2")); // per username+IP key
Assert.False(throttle.IsLockedOut("bob", "10.0.0.1"));
clock.Advance(TimeSpan.FromMinutes(6)); // lockout window passed
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
}
[Fact]
public void SuccessResets()
{
var clock = new TestTimeProvider(Start);
var throttle = Create(clock);
for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1");
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
throttle.RecordSuccess("alice", "10.0.0.1");
// A single failure after the reset must NOT lock out — the counter restarted.
throttle.RecordFailure("alice", "10.0.0.1");
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
}
[Fact]
public void KeyIsCaseInsensitiveOnUsername()
{
var clock = new TestTimeProvider(Start);
var throttle = Create(clock);
for (var i = 0; i < 5; i++) throttle.RecordFailure("Alice", "10.0.0.1");
Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
Assert.True(throttle.IsLockedOut("ALICE", "10.0.0.1"));
}
[Fact]
public void FailuresOutsideWindow_DoNotAccumulate()
{
var clock = new TestTimeProvider(Start);
var throttle = Create(clock);
// Four failures, then let the failure window lapse before the fifth.
for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1");
clock.Advance(TimeSpan.FromMinutes(6)); // > LoginFailureWindowMinutes (5)
throttle.RecordFailure("alice", "10.0.0.1");
// The window reset, so only one failure is counted — no lockout.
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
}
[Fact]
public void LockoutPersistsUntilLockoutMinutesElapse()
{
var clock = new TestTimeProvider(Start);
var throttle = Create(clock);
for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
clock.Advance(TimeSpan.FromMinutes(4)); // still inside the 5-minute lockout
Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
clock.Advance(TimeSpan.FromMinutes(2)); // now past the 5-minute lockout
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
}
[Fact]
public void ZeroMaxFailures_DisablesThrottling()
{
var clock = new TestTimeProvider(Start);
var throttle = Create(clock, new SecurityOptions { MaxLoginFailuresPerWindow = 0 });
for (var i = 0; i < 100; i++) throttle.RecordFailure("alice", "10.0.0.1");
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
}
}
@@ -1242,6 +1242,28 @@ public class AuthorizationPolicyTests
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, verifierOnly));
}
[Theory]
[InlineData("Operator")]
[InlineData("Verifier")]
[InlineData("Administrator")]
public async Task RequireSecuredWriteAccess_SecuredWriteRoles_Satisfy(string role)
{
// arch-review UA1: the Secured Writes history exposes process-sensitive
// tag values, so read access is any-of {Operator, Verifier, Administrator}.
var principal = CreatePrincipal(new[] { role });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireSecuredWriteAccess, principal));
}
[Theory]
[InlineData("Viewer")]
[InlineData("Designer")]
[InlineData("Deployer")]
public async Task RequireSecuredWriteAccess_OtherRoles_Denied(string role)
{
var principal = CreatePrincipal(new[] { role });
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireSecuredWriteAccess, principal));
}
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
{
var claims = new List<Claim>();