Compare commits
81 Commits
f6eef9d8eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ad15fcf93 | |||
| 795af1da71 | |||
| 65c6b99cf2 | |||
| 420c2e0783 | |||
| 3cacdca861 | |||
| ade49c0935 | |||
| 02480d4fbc | |||
| 87bb647efb | |||
| a0cec479b6 | |||
| 91baba8fcc | |||
| 9688707d82 | |||
| 509114360d | |||
| cfd371ce18 | |||
| 51a956a878 | |||
| 2a3baae301 | |||
| 10fc22b87e | |||
| 8825cd966f | |||
| 69a7f8e1b5 | |||
| 01a6313fe7 | |||
| e7cfa21fda | |||
| 68f1b56acf | |||
| b4da0deacb | |||
| 180403cb17 | |||
| 67c182ce18 | |||
| 05042e5e36 | |||
| aed5fbf046 | |||
| 00ef22823f | |||
| c05a6d93b0 | |||
| ec2615a9d2 | |||
| 2a0b34e056 | |||
| 4555a3bdad | |||
| 681606dad0 | |||
| aa7093b53b | |||
| 17cb91a315 | |||
| 0adfb244b2 | |||
| c6e60cb6c4 | |||
| c713925ac7 | |||
| 0b056c6fee | |||
| 484b176cd6 | |||
| dd1eea2106 | |||
| 63cc772e8d | |||
| 3b4d81e630 | |||
| b6f5eb2535 | |||
| a8f5f66647 | |||
| df21d365ec | |||
| a2b40b7922 | |||
| 3c91f0f813 | |||
| a6c4161719 | |||
| 78ca2a6744 | |||
| 21a95c7f50 | |||
| c43d7ee1ad | |||
| d0b66bf105 | |||
| 44421dc5aa | |||
| 8fa7631ec4 | |||
| b62cfad48d | |||
| 625f207e08 | |||
| 29bfa8fbf9 | |||
| b0582eb66a | |||
| 9090b1b511 | |||
| e3d146aa21 | |||
| 177881c423 | |||
| 3c42069673 | |||
| 00ac436ae2 | |||
| 302d1245cd | |||
| fa0ca66a05 | |||
| 711e8beb2b | |||
| 9ac6ed51d3 | |||
| efd5d881dc | |||
| 0793159cbb | |||
| 51b6f1ab9f | |||
| 29d0c50f07 | |||
| adbf2eb182 | |||
| b392b84f21 | |||
| 5c73be486d | |||
| 308742b7a6 | |||
| 8cfe076012 | |||
| 646af887d1 | |||
| 1e0bfe03d4 | |||
| 9934bea05a | |||
| d0079ba1a1 | |||
| 4dda768801 |
@@ -187,8 +187,8 @@ Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on
|
||||
Manager queries; every writer sproc follows the own-or-join `TRY/CATCH` template with locked lookups;
|
||||
every `.sql` on disk must be in `Database.sqlproj`'s `<Build Include>` list; optimistic concurrency via
|
||||
`ExpectedLastUpdate` → `THROW 50014`.
|
||||
- **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` +
|
||||
`${secret:btdb/ldap/serviceaccountpassword}`, store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`,
|
||||
- **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` (the LDAP
|
||||
service-account secret is gone since 2026-10-01 — see below; only `dotnet run` against local glauth uses it), store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`,
|
||||
master key only in `ZB_SECRETS_MASTER_KEY`. Rotation is cut-over step 4.2.
|
||||
- **Claude artifacts are gitignored + untracked in btdb** (2026-09-11): `CLAUDE.md`, `.claude/`,
|
||||
`docs/plans/`, `docs/reviews/`, `*.tasks.json` — they exist on disk (btdb's `CLAUDE.md` is the repo
|
||||
@@ -197,3 +197,11 @@ Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on
|
||||
- **Open step:** phase-4 cut-over on the DEV box (`docs/operations/cut-over.md`; 4.1 parity walk-through
|
||||
gates the rest) — **blocked by the 2026-09-11 remote-access hold** (no psbridge, no winrm; UNC copies and
|
||||
direct SQL logins still allowed). §6 item 6 (which SQL host carries DEV `BT`) is still unrecorded.
|
||||
- **2026-10-01 — LDAP sign-in by user bind** (merged `a81a480`): Auth 0.4.0/0.4.1 added opt-in
|
||||
`LdapOptions.BindMode=User`; BTDB ships it. Sign-in binds as `UserBindTemplate` (`<DOMAIN>\{0}` —
|
||||
down-level maps exactly to the sAMAccountName the group search uses), the same connection reads the
|
||||
user's own `memberOf`/`displayName`, and the username returned is the directory's spelling. No service
|
||||
account, no LDAP secret; the preflight requires the template and refuses `AllowInsecure`/`Transport=None`
|
||||
with user bind outside Development. Local dev + docker rig stay `BindMode=ServiceAccount` (GLAuth bind DNs
|
||||
embed the user's OU). Rollout to the three Manager nodes (vd03 first) and retiring the stored secret are
|
||||
the open steps (plan `docs/plans/2026-10-01-ldap-user-bind.md`, gitignored).
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
# ScadaBridge — index history
|
||||
|
||||
- **2026-10-13 existing fixes verified and issues closed.** Main `513fa1aa` already includes #35 fail-closed unavailable configured body/SQL redactors (`006202f3`) and #37 Flow Debugger structured/raw validated method inputs (`b5eb08f1`). Fresh full AuditLog project 407 passed/0 failed; FlowDebuggerPageTests 47 passed/0 failed; both terminal exit0. Remote ancestry confirmed; #35/#37 closed with evidence. No source changes/deployment/MES transactions. #36 runtime reset-echo overload is implemented, but QA/prod script port remains tracked; #30 per-site PSK deployment gate remains open.
|
||||
|
||||
- **2026-10-13 #36 runtime regression proof refreshed.** Rebuilt HandshakeResetEchoTests passed all three cases with local fakes, terminal exit0; an earlier no-build attempt matched no tests and is excluded from evidence. Tracker now distinguishes implemented runtime b623fdb0 from remaining QA/prod database-owned script rollout (deployment register row119, scripts13/14/36 and15/16/25/26/37/38). No database writes, deployments or fresh live rig claims.
|
||||
@@ -36,11 +36,11 @@ own `CLAUDE.md` for the full picture. See [Refreshing this index](#refreshing-th
|
||||
|
||||
| Project | Location | Stack | Repo | Summary |
|
||||
|---|---|---|---|---|
|
||||
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **two-subtree address space (v3.0, `master` since 2026-07-16)** — `https://zb.com/otopcua/raw` (device tree Folder→Driver→Device→TagGroup→Tag, NodeId `s=<RawPath>`) and `https://zb.com/otopcua/uns` (equipment tree Area→Line→Equipment→signal); every value has one source fanned to both NodeIds. **This is the wire contract ScadaBridge binds against** (ScadaBridge consumes v3 end-to-end; its #14 is closed). Drivers: Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient, `Sql` (SQL Server / PostgreSQL / Oracle, read-only poll), `Mqtt` (Plain + Sparkplug B, writable where a tag declares a destination), `MTConnect` (read-only), Calculation, and AVEVA Galaxy as a standard Equipment-kind driver via in-process `GalaxyDriver` → gRPC → **mxaccessgw**. Sole historian backend is **HistorianGateway**. Address-space availability guarantee: an unreadable deployment artifact is *no answer*, never an empty configuration — nodes hold last-known-good, and a driver-only node boots and serves from its LocalDb cache with central SQL down (ServiceLevel stays 240/250). Redundancy: one independent 2-node Akka mesh per application `Cluster`; Primary = oldest Up `driver` member (ServiceLevel 250) + one Secondary (240) **per `Cluster`**; cross-mesh commands ride gRPC with per-cluster PSK auth (Akka `ClusterClient` fully deleted 2026-08-06). Data plane is machine-to-machine: OPC UA UserName `apikey` + `otua_<keyId>_<secret>` minted on AdminUI `/api-keys`; `OpcUa:AllowAnonymous` defaults `true` (anonymous is a normal grantable principal); LDAP is AdminUI-login only. Node ACLs are enforced **only** behind `Security:Acl:Enabled` (default `false`); with it on, scoped `NodeAcl` grants decide **every** data-plane operation incl. browse/read/subscribe, fail closed, and reach open sessions. Every driver's authoring path now consults the driver's own runtime parse, so what authoring accepts is what the driver serves (tracker zeroed 2026-08-10, origin `d06e92c6`). **Full change history, live-gate records and the per-issue consumer-impact record for ScadaBridge: `.claude/skills/sister-project-history/otopcua.md` (skill `sister-project-history`).** |
|
||||
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** |
|
||||
| **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | `gitea.dohertylan.com/dohertj2/ScadaBridge` | **Current `main`: `9d529b82`, pushed 2026-09-11 — adds the Gitea #36 reset-aware handshake (`WriteBatchAndWaitAsync` reset-echo overload), the computed-alarm `IsMesReportable` flag (template checkbox + nullable instance override, `Alarms.MesReportableAsync()`, migration `AddAlarmMesReportable`; native alarms stay on the 900–999 band) and a nav fix nesting Flow Debugger in the Deployment section. DEPLOYED: still `2b9a7c3c` on DEV vd03 and the QA pair vq03/vq04 (2026-09-10, migration 53→55, protocol config row encrypted → rollback is the DB restore; records `docs/deployment/deployment-records/2026-09-10-wonder-app-{vd03,vq03-vq04}-2b9a7c3c.md`) — the 2026-09-11 tip is NOT deployed, blocked by the wonder-box remote-access hold.** Management/API/CLI parity complete (`docs/plans/2026-09-10-management-api-cli-parity-review.md`): all 149 eligible contracts exposed in 20 CLI groups, 19 missing operations added, deadline/registry/outcome gaps fixed; CLI documentation checked against all 159 leaf commands; 9554 tests verified and primary 8-node rig rebuilt with read-only smoke checks. Previously completed 2026-09-09 whole-system arch review remediation (`docs/plans/2026-09-09-arch-review.md`: 92 findings; all 13 High + 33 Medium fixed; all 46 Low findings also committed and pushed (§11); 9478 solution tests and deployed browser smoke pass, primary 8-node rig rebuilt). Adds the `ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker` child-process project and a coordinated central-pair migration that encrypts DataConnection protocol config at rest (old binaries cannot read protected rows). Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. **Inbound API keys live on the shared SQL Server store on a central pair** (design 2026-09-02, ScadaBridge register row 108): ScadaBridge is Auth's first consumer of the new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package at `0.3.0`, pointing both central nodes at one `zbauth` schema in the configuration database instead of each node's own per-node SQLite file. **Flow Debugger (including the instance picker and v1.4 step logs, 2026-09-09):** Central UI + CLI trace of one inbound-API method or value-change flow across Central / Site / Tags / External swimlanes, Live or Replay, built on `ScriptTrace` audit rows and the outbound-call audit rows; arch-reviewed and remediated the same day (F1–F13 — redacted + byte-capped inline trace copy, route-time site scope on Live runs, Replay open to `OperationalAudit` roles under `RequireFlowDebugger`; `docs/plans/2026-09-06-flow-debugger-arch-review.md` + `-remediation.md`); script entry (site → instance → script, 2026-09-08). |
|
||||
| **HistorianGateway** | `~/Desktop/HistorianGateway` | .NET 10 x64, gRPC, Blazor | `gitea.dohertylan.com/dohertj2/historiangw` | Single-process gRPC sidecar exposing (1) a full read/write API to the AVEVA Historian (5 gRPC services; 15 retrieval modes; historical/backfill writes; tag-config lifecycle; SQL live-value path; store-forward + redundancy resilience; all default-disabled) and (2) read-only Galaxy object-hierarchy browse via the shared `ZB.MOM.WW.GalaxyRepository` lib. No COM, no x86 worker. **Owns `AVEVA.Historian.Client`** at `histsdk/` (ordinary owned source, edited in place — the re-vendor model is retired). Dev: dashboard `:5220` (HTTP/1.1) + gRPC h2c `:5221`; production: one `Kestrel:Endpoints:Https` with `Protocols: Http1AndHttp2` (ALPN). Default-on leased session pools amortize the historian handshake (v6 pool + a separate v8 event-session pool). Client lib `ZB.MOM.WW.HistorianGateway.Client` + Contracts are on the Gitea feed at `0.3.0`; **OtOpcUa is the first consumer** (its sole historian backend since PR #423); ScadaBridge has no coupling. Known 2023 R2 server-side gates: native event reads return 0 rows (C2, won't-fix — SQL-path `ReadEvents` via `Runtime.dbo.Events` is the shipped workaround), `Int1`/`UInt1` analog-tag creation is degenerate (OtOpcUa maps Boolean → `Int2`), string historical writes non-convergent (follow-ups #18–#25). Only remaining archreview item: the secure-by-default TLS flip, deferred to 1.0.0. **Full change history (ArchReview cycles 1–2, pending.md items, PR list): `.claude/skills/sister-project-history/historiangateway.md`.** |
|
||||
| **BTDB** (Batch Tracker) | `~/Desktop/btdb` — local `master` @ `695192d` (2026-09-11), **~198 commits ahead of `origin/master` (`d147c10`, 2023) and unpushed**; no branch in flight (`feat/transfer-machine-batch` and `fix/transfer-lot-walk` — DB-6 reopened: forward loop guard, whole-lot chain walks incl. merges — both merged 2026-09-11; `feat/manager-transfers` — the Manager edits, shows and audits transfers — merged `695192d` the same day). **Claude artifacts are gitignored + untracked there** (`CLAUDE.md`, `.claude/`, `docs/plans/`, `docs/reviews/`, `*.tasks.json`) — local only, never to the corporate remote. | SQL Server SSDT project + SQL CLR (net472; **Windows-only build**, the docker rig rebuilds it cross-platform from the same sources); **BTDB Manager = Blazor Web App net10.0** on the family libs (Auth 0.3.0, Theme 0.4.1, Health 0.3.0, Telemetry + Configuration 0.1.0, Secrets 0.6.2), Dapper + embedded queries, self-contained win-x64 NSSM service `ZB.MOM.WW.BTDB.Manager` on `:9877` (→ `:9876` after cut-over) | **`ZimmerBiomet/BTDB`** on the corporate Gitea `wonder-app-vd03:3000` (not on `gitea.dohertylan.com`) | The `BT` SQL Server database that is the plant's **batch / cycle / work-order system of record for data collection**. Galaxy machine objects write through `StartMachineBatch` / `StartMachineCycle` / `End*` / `AddMachineBatchWorkOrder` / `AddOtherData*` / `AddMachineBatchTransfer` / **`TransferMachineBatch`** (new 2026-09-11: end source if open + start destination by `Code` + copy not-moved-out work orders + link, one owned transaction) (own-or-join transaction template, `THROW 50000+`, all `GETUTCDATE()`); reports pull per-batch time-series **out of the AVEVA Historian through CLR sprocs** (`OPENQUERY(INSQL, …)` + `Runtime.dbo.*`), so **BT must be co-tenant with the Historian's `Runtime` DB** (`wonder-sql-*`). MESAPI and **ScadaBridge's inbound API** resolve `SAPID → Machine.Code`; no checked-in ScadaBridge script calls a BT sproc. **State 2026-09-11 (all merged on local `master`):** the Blazor conversion phases 1–3 (legacy net6.0 MVC app, Telerik reports and the mis-named `install.bat` deleted), the 2026-09-04 full review remediation (12 work packages, 60 findings fixed, 0 open; static invariant tests over the SQL + CLR sources, opt-in rig integration tests behind `BTDB_TEST_CONNECTION`, Gitea Actions gate `./build.sh`), and the DARS sprint-1 schema (`MachineBatchTransfer`, `ContainerID`/`MoveOutTimestamp` on `MachineBatchWO`, nestable `MachineGroup.ParentMachineGroupID`, Gen1 reactor load script) — the old `DARS/WW_ARCH/BatchTracker_DB/` fork and the `feat/manager-blazor` worktree are superseded. **Phase-4 cut-over on the DEV box** (`docs/operations/cut-over.md`) is the open step and is **blocked by the 2026-09-11 remote-access hold**. Repo orientation lives in btdb's own (gitignored) `CLAUDE.md`. **Full schema/contract notes, family edges and the gap analysis: `.claude/skills/sister-project-history/btdb.md`.** **Local Docker rig:** `btdb/docker/` — runtime-only image on `:9877` joined to `scadabridge-net` + the redundant glauth pair, registered in Overview as **BTDB Manager**; `docker/deploy-db.sh` publishes `BT` (dacpac + CLR via two side projects under `docker/db/`, plus a `Runtime` stand-in) into `scadabridge-mssql`; `smoke-queries.sh`, `validate-config-import.sh`, `db/validate-clr.sql` and `Database/Scripts/Validation/*.sql` are the rig gates. |
|
||||
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **two-subtree address space (v3.0, `master` since 2026-07-16)** — `https://zb.com/otopcua/raw` (device tree Folder→Driver→Device→TagGroup→Tag, NodeId `s=<RawPath>`) and `https://zb.com/otopcua/uns` (equipment tree Area→Line→Equipment→signal); every value has one source fanned to both NodeIds. **This is the wire contract ScadaBridge binds against** (ScadaBridge consumes v3 end-to-end; its #14 is closed). Drivers: Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient, `Sql` (SQL Server / PostgreSQL / Oracle, read-only poll), `Mqtt` (Plain + Sparkplug B, writable where a tag declares a destination), `MTConnect` (read-only), Calculation, and AVEVA Galaxy as a standard Equipment-kind driver via in-process `GalaxyDriver` → gRPC → **mxaccessgw**. Historian backend is pluggable (`ServerHistorian:Backend`): **HistorianGateway** (default), **SqlServer** or **QuestDb** — the latter two add typed values with real status and OPC UA HistoryUpdate (QuestDB: soft delete + TTL retention). Address-space availability guarantee: an unreadable deployment artifact is *no answer*, never an empty configuration — nodes hold last-known-good, and a driver-only node boots and serves from its LocalDb cache with central SQL down (ServiceLevel stays 240/250). Redundancy: one independent 2-node Akka mesh per application `Cluster`; Primary = oldest Up `driver` member (ServiceLevel 250) + one Secondary (240) **per `Cluster`**; cross-mesh commands ride gRPC with per-cluster PSK auth (Akka `ClusterClient` fully deleted 2026-08-06). Data plane is machine-to-machine: OPC UA UserName `apikey` + `otua_<keyId>_<secret>` minted on AdminUI `/api-keys`; `OpcUa:AllowAnonymous` defaults `true` (anonymous is a normal grantable principal); LDAP is AdminUI-login only. Node ACLs are enforced **only** behind `Security:Acl:Enabled` (default `false`); with it on, scoped `NodeAcl` grants decide **every** data-plane operation incl. browse/read/subscribe, fail closed, and reach open sessions. Every driver's authoring path now consults the driver's own runtime parse, so what authoring accepts is what the driver serves (tracker zeroed 2026-08-10, origin `d06e92c6`). **Full change history, live-gate records and the per-issue consumer-impact record for ScadaBridge: `.claude/skills/sister-project-history/otopcua.md` (skill `sister-project-history`).** |
|
||||
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** Alarm ack (main `488649c`, 2026-10-20) authenticates as the operator and writes `<alarm>.AckMsg` so the Historian records the real operator; `AcknowledgeAlarmRequest` gained `operator_password`/`operator_domain` (proto change; OtOpcUa's `GatewayGalaxyAlarmAcknowledger` must send the password before it deploys against this). |
|
||||
| **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | `gitea.dohertylan.com/dohertj2/ScadaBridge` | **Current `main`: `3a217b94` (2026-09-29: stale per-node secret-mirror status rows are now pruned; 2026-09-28: site-secrets follow-ups — a central secret-store outage fails only the sites that reference a secret, removal-only deploys are audited, each site node's mirror failures (standby included) ride the health report via a 12th replicated table `secret_mirror_status`, artifact-deploy CLI commands exit 1 on a failed site; rig gate 9/9). Previous tip `23a139a4` (2026-09-28: site secrets ride the artifact deploy and the gRPC secrets hub is retired — see below). Previous tip `5cc09b08` (2026-09-28: site scripts gain a read-only, allow-listed `Files` global — `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots`, off by default — used by the in-memory MES/Delmia/alarm-API test machines SBTEST01/02 (`docker/seed-mes-test-machine.sh`) to parse downloaded recipe files; DEV vd03 and QA vq03/vq04 on `b3021e7c` since 2026-09-28, migrations 57; register row 126 closed) — adds site artifact staleness (`Site.ArtifactRevisionHash` stamped on every successful artifact deploy, migration `AddSiteArtifactRevision`; Sites page *Artifacts current / out of date / never deployed* badge), a rail-footer **Pending updates** indicator over a process-wide `PendingUpdatesCache` (stale instances + stale-artifact sites, Deployer/Admin), and a scrollable rail nav; design `docs/plans/2026-09-14-pending-updates-design.md`. Previous tip `9d529b82` (2026-09-11) — adds the Gitea #36 reset-aware handshake (`WriteBatchAndWaitAsync` reset-echo overload), the computed-alarm `IsMesReportable` flag (template checkbox + nullable instance override, `Alarms.MesReportableAsync()`, migration `AddAlarmMesReportable`; native alarms stay on the 900–999 band) and a nav fix nesting Flow Debugger in the Deployment section. DEPLOYED: `b3021e7c` on DEV vd03 and the QA pair vq03/vq04 (2026-09-28, migrations 55→57; records `docs/deployment/deployment-records/2026-09-28-wonder-app-{vd03-fe89363b,vq03-vq04-b3021e7c}.md`).** **Merged `23a139a4` (2026-09-28): site secrets now ride the artifact deploy and the pull-only gRPC secrets hub is retired in ScadaBridge** (design `docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md`; central shares one secret store via `Secrets:Store:Provider=SqlServer`; rig live gate PASS 8/8, vd03/QA NOT deployed — rollout tracked as ScadaBridge register row 127 — see `ZB.MOM.WW.Secrets/CLAUDE.md` for the unpublished `0.6.3` hub-logging work this superseded for ScadaBridge). Management/API/CLI parity complete (`docs/plans/2026-09-10-management-api-cli-parity-review.md`): all 149 eligible contracts exposed in 20 CLI groups, 19 missing operations added, deadline/registry/outcome gaps fixed; CLI documentation checked against all 159 leaf commands; 9554 tests verified and primary 8-node rig rebuilt with read-only smoke checks. Previously completed 2026-09-09 whole-system arch review remediation (`docs/plans/2026-09-09-arch-review.md`: 92 findings; all 13 High + 33 Medium fixed; all 46 Low findings also committed and pushed (§11); 9478 solution tests and deployed browser smoke pass, primary 8-node rig rebuilt). Adds the `ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker` child-process project and a coordinated central-pair migration that encrypts DataConnection protocol config at rest (old binaries cannot read protected rows). Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. **Inbound API keys live on the shared SQL Server store on a central pair** (design 2026-09-02, ScadaBridge register row 108): ScadaBridge is Auth's first consumer of the new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package at `0.3.0`, pointing both central nodes at one `zbauth` schema in the configuration database instead of each node's own per-node SQLite file. **Flow Debugger (including the instance picker and v1.4 step logs, 2026-09-09):** Central UI + CLI trace of one inbound-API method or value-change flow across Central / Site / Tags / External swimlanes, Live or Replay, built on `ScriptTrace` audit rows and the outbound-call audit rows; arch-reviewed and remediated the same day (F1–F13 — redacted + byte-capped inline trace copy, route-time site scope on Live runs, Replay open to `OperationalAudit` roles under `RequireFlowDebugger`; `docs/plans/2026-09-06-flow-debugger-arch-review.md` + `-remediation.md`); script entry (site → instance → script, 2026-09-08). |
|
||||
| **HistorianGateway** | `~/Desktop/HistorianGateway` | .NET 10 x64, gRPC, Blazor | `gitea.dohertylan.com/dohertj2/historiangw` | Single-process gRPC sidecar exposing (1) a full read/write API to the AVEVA Historian (5 gRPC services; 15 retrieval modes; historical/backfill writes; tag-config lifecycle; SQL live-value path; store-forward + redundancy resilience; all default-disabled) and (2) read-only Galaxy object-hierarchy browse via the shared `ZB.MOM.WW.GalaxyRepository` lib. No COM, no x86 worker. **Owns `AVEVA.Historian.Client`** at `histsdk/` (ordinary owned source, edited in place — the re-vendor model is retired). Dev: dashboard `:5220` (HTTP/1.1) + gRPC h2c `:5221`; production: one `Kestrel:Endpoints:Https` with `Protocols: Http1AndHttp2` (ALPN). Default-on leased session pools amortize the historian handshake (v6 pool + a separate v8 event-session pool). Client lib `ZB.MOM.WW.HistorianGateway.Client` + Contracts are on the Gitea feed at `0.3.0`; **OtOpcUa is the first consumer** (its sole historian backend since PR #423); ScadaBridge has no coupling. Known 2023 R2 server-side gates: native event reads return 0 rows (C2, won't-fix — SQL-path `ReadEvents` via `Runtime.dbo.Events` is the shipped workaround), `Int1`/`UInt1` analog-tag creation is degenerate (OtOpcUa maps Boolean → `Int2`), string historical writes non-convergent (follow-ups #18–#25). Only remaining archreview item: the secure-by-default TLS flip, deferred to 1.0.0. A Java client, `com.zb.mom.ww.historiangw:zb-mom-ww-historiangw-client:0.1.0` (Gitea Maven feed, `clients/java/`), is consumed by the ignitionoee repo's `modules/historian` Ignition module. **Full change history (ArchReview cycles 1–2, pending.md items, PR list): `.claude/skills/sister-project-history/historiangateway.md`.** |
|
||||
| **BTDB** (Batch Tracker) | `~/Desktop/btdb` — local `master` @ `a81a480` (2026-10-01), **~211 commits ahead of `origin/master` (`d147c10`, 2023) and unpushed**; no branch in flight (`feat/transfer-machine-batch` and `fix/transfer-lot-walk` — DB-6 reopened: forward loop guard, whole-lot chain walks incl. merges — both merged 2026-09-11; `feat/manager-transfers` — the Manager edits, shows and audits transfers — merged `695192d` the same day). **Claude artifacts are gitignored + untracked there** (`CLAUDE.md`, `.claude/`, `docs/plans/`, `docs/reviews/`, `*.tasks.json`) — local only, never to the corporate remote. | SQL Server SSDT project + SQL CLR (net472; **Windows-only build**, the docker rig rebuilds it cross-platform from the same sources); **BTDB Manager = Blazor Web App net10.0** on the family libs (Auth 0.4.1 — **sign-in by user bind, `BindMode=User`, no LDAP service account**, Theme 0.4.1, Health 0.3.0, Telemetry + Configuration 0.1.0, Secrets 0.6.2), Dapper + embedded queries, self-contained win-x64 NSSM service `ZB.MOM.WW.BTDB.Manager` on `:9877` (→ `:9876` after cut-over) | **`ZimmerBiomet/BTDB`** on the corporate Gitea `wonder-app-vd03:3000` (not on `gitea.dohertylan.com`) | The `BT` SQL Server database that is the plant's **batch / cycle / work-order system of record for data collection**. Galaxy machine objects write through `StartMachineBatch` / `StartMachineCycle` / `End*` / `AddMachineBatchWorkOrder` / `AddOtherData*` / `AddMachineBatchTransfer` / **`TransferMachineBatch`** (new 2026-09-11: end source if open + start destination by `Code` + copy not-moved-out work orders + link, one owned transaction) (own-or-join transaction template, `THROW 50000+`, all `GETUTCDATE()`); reports pull per-batch time-series **out of the AVEVA Historian through CLR sprocs** (`OPENQUERY(INSQL, …)` + `Runtime.dbo.*`), so **BT must be co-tenant with the Historian's `Runtime` DB** (`wonder-sql-*`). MESAPI and **ScadaBridge's inbound API** resolve `SAPID → Machine.Code`; no checked-in ScadaBridge script calls a BT sproc. **State 2026-09-11 (all merged on local `master`):** the Blazor conversion phases 1–3 (legacy net6.0 MVC app, Telerik reports and the mis-named `install.bat` deleted), the 2026-09-04 full review remediation (12 work packages, 60 findings fixed, 0 open; static invariant tests over the SQL + CLR sources, opt-in rig integration tests behind `BTDB_TEST_CONNECTION`, Gitea Actions gate `./build.sh`), and the DARS sprint-1 schema (`MachineBatchTransfer`, `ContainerID`/`MoveOutTimestamp` on `MachineBatchWO`, nestable `MachineGroup.ParentMachineGroupID`, Gen1 reactor load script) — the old `DARS/WW_ARCH/BatchTracker_DB/` fork and the `feat/manager-blazor` worktree are superseded. **Phase-4 cut-over on the DEV box** (`docs/operations/cut-over.md`) is the open step and is **blocked by the 2026-09-11 remote-access hold**. Repo orientation lives in btdb's own (gitignored) `CLAUDE.md`. **Full schema/contract notes, family edges and the gap analysis: `.claude/skills/sister-project-history/btdb.md`.** **Local Docker rig:** `btdb/docker/` — runtime-only image on `:9877` joined to `scadabridge-net` + the redundant glauth pair, registered in Overview as **BTDB Manager**; `docker/deploy-db.sh` publishes `BT` (dacpac + CLR via two side projects under `docker/db/`, plus a `Runtime` stand-in) into `scadabridge-mssql`; `smoke-queries.sh`, `validate-config-import.sh`, `db/validate-clr.sql` and `Database/Scripts/Validation/*.sql` are the rig gates. |
|
||||
|
||||
## Cross-project relationships
|
||||
|
||||
@@ -159,14 +159,14 @@ each project's **code-verified current state**, and the **gaps** between. See
|
||||
|
||||
| Component | Status | Goal | Design | Implementation |
|
||||
|---|---|---|---|---|
|
||||
| Auth (login / identity / authz) | Adopted. Feed carries **`0.3.0`** (2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||
| Auth (login / identity / authz) | Adopted. Feed carries **`0.4.1`** (2026-10-01; 0.4.0 the same day) (2026-10-01; opt-in user bind) — the four apps still pin `0.3.0` (released 2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). **0.4.0 (2026-10-01) — opt-in LDAP user bind:** adds `LdapOptions.BindMode` (`ServiceAccount` default | `User`), `UserBindTemplate` and `AcceptDomainQualifiedNames`; in `User` mode `LdapAuthService` binds as the signing-in user and reads their own groups on the same connection, so no service account is needed. Bind failures map to `BadCredentials` (bad password, unusable name, or bind result 49) or `GroupLookupFailed` (zero/multiple entries or no groups after a good bind), other bind/connect/search errors fail over to the next endpoint, and `ServiceAccount` stays the default so no existing consumer needs to change. **0.4.1** fixes the review findings: `Username` is canonicalised from the entry's `UserNameAttribute`, empty passwords are refused in both bind modes (a `ServiceAccount`-mode behaviour change), and the docs prefer the down-level `EXAMPLE\{0}` template over UPN. First consumer: BTDB Manager; ScadaBridge/HistorianGateway/OtOpcUa/MxAccessGateway stay on `0.3.0` until they move to AD. `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||
| UI Theme (layout / tokens / components) | Adopted. Feed `0.2.0`–`0.4.1`; **five** consumers (the ignitionoee router joined), all on `0.4.1` and pushed (2026-08-11); Every deployed wonder box (DEV vd03 + QA vq03/vq04) runs its repo tip as of 2026-09-10, so the deployed Theme is 0.4.1 everywhere. **On bump, TRIM the local `.btn` block, don't delete it** — mxgw's carried more than sizing. Narrative: [`ZB.MOM.WW.Theme/CLAUDE.md`](ZB.MOM.WW.Theme/CLAUDE.md). | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
|
||||
| Health (readiness / liveness / active-node) | **Adopted** — lib `0.3.0`, all 4 apps reference it + wire `MapZbHealth`. `0.2.1`/`0.3.0` changed the active tier's behaviour (standby now answers **503**; active = oldest `Up` member via `ClusterActiveNode`, not `RoleLeader` — the two diverge after any restart), and both Akka apps' private copies were deleted in its favour (merged + pushed). Narrative: [`ZB.MOM.WW.Health/CLAUDE.md`](ZB.MOM.WW.Health/CLAUDE.md). | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
|
||||
| Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
|
||||
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed — the "local only / not yet pushed" caveat was stale) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
|
||||
| Audit (event model + writer seam) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed to origin) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
||||
| Galaxy Repository (object-hierarchy SQL browse + gRPC service) | **Adopted** (feed `0.1.0` + `0.2.0`; consumed at `0.2.0` by HistorianGateway **and mxaccessgw**, whose Server wires `AddZbGalaxyRepository` — verified 2026-07-18; the prior "mxaccessgw adoption is a follow-on" claim was stale) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) |
|
||||
| Secrets (encrypted store + `${secret:}` resolution) | Built; family version **`0.6.2`**, published. Pins: **all four apps `0.6.2`** (verified against each repo's checked-in pins AND its origin default branch 2026-09-03 — the earlier "OtOpcUa unpushed / mxgw 0.6.0 / ScadaBridge 0.5.1" notes were stale). **Target `0.6.1`+, never `0.6.0`, and use the 4-arg `AddZbSecrets(..., contentRootPath)` overload at every pre-host `${secret:}` expander call site — the pin alone does not close the hole.** `Secrets:SqlitePath` must be absolute and outside the content root (fail-closed, no opt-out; the 2026-08-09 mxgw outage). Production topologies decided (scadaproj#3/#4 closed): OtOpcUa = Akka pair-local, ScadaBridge = gRPC hub over ONE shared central SQL store (`0.5.1` minimum for central pairs); both default-off. Full narrative (`0.1.x`→`0.6.2`, incidents, live gates, container caveat, `NU1102` note): [`ZB.MOM.WW.Secrets/CLAUDE.md`](ZB.MOM.WW.Secrets/CLAUDE.md). | Shared `ZB.MOM.WW.Secrets` lib (5 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) |
|
||||
| Secrets (encrypted store + `${secret:}` resolution) | Built; family version **`0.6.3`** (published 2026-09-28; apps still pin `0.6.2`). Pins: **all four apps `0.6.2`** (verified against each repo's checked-in pins AND its origin default branch 2026-09-03 — the earlier "OtOpcUa unpushed / mxgw 0.6.0 / ScadaBridge 0.5.1" notes were stale). **Target `0.6.1`+, never `0.6.0`, and use the 4-arg `AddZbSecrets(..., contentRootPath)` overload at every pre-host `${secret:}` expander call site — the pin alone does not close the hole.** `Secrets:SqlitePath` must be absolute and outside the content root (fail-closed, no opt-out; the 2026-08-09 mxgw outage). Production topologies decided (scadaproj#3/#4 closed): OtOpcUa = Akka pair-local (still on the hub for its own topology); **ScadaBridge retired the gRPC secrets hub for itself (2026-09-28, ScadaBridge `main` @ `23a139a4`, follow-ups `2650f794`) — sites now receive secrets via Deploy Artifacts instead of the hub, central still on ONE shared SQL store (`Secrets:Store:Provider=SqlServer`, `0.5.1` minimum)**; both default-off elsewhere. **`0.6.3` (hub sweep outcome logging + public `ISecretsHubSyncStatus`) is PUBLISHED to the feed (2026-09-28) but pinned by no app** — ScadaBridge, its original motivation, retired the hub for itself; it is there for the other hub consumer (OtOpcUa) to adopt. Full narrative (`0.1.x`→`0.6.3`, incidents, live gates, container caveat, `NU1102` note): [`ZB.MOM.WW.Secrets/CLAUDE.md`](ZB.MOM.WW.Secrets/CLAUDE.md). | Shared `ZB.MOM.WW.Secrets` lib (5 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) |
|
||||
| LocalDb (embedded cache + 2-node sync) | **Adopted** by ScadaBridge (Phases 1+2 merged; pins **`0.3.4`**) and OtOpcUa (Phases 1+2 merged; pins **`0.3.4`** since 2026-09-03, `b8b4c18f`) — no version skew remains (every release is wire-compatible, so one would be supported). Replication is **default-OFF**; enabled on the docker rigs' site-a pairs and on the **QA pair vq03/vq04** (ScadaBridge site pair + OtOpcUa pair — OtOpcUa's sync only actually CONNECTED after the 2026-09-10 `b8b4c18f` upgrade; the old build answered the sync POST with the login 302); DEV vd03 is single-node. Operational constraints: a pair stops/starts together (no rolling upgrade), a node offline > `TombstoneRetention` (7 d) can resurrect deleted rows. Full release narrative (`0.1.1`→`0.3.4`) + gates: [`ZB.MOM.WW.LocalDb/CLAUDE.md`](ZB.MOM.WW.LocalDb/CLAUDE.md). | Shared `ZB.MOM.WW.LocalDb` lib | [`docs/plans/2026-07-17-localdb-design.md`](docs/plans/2026-07-17-localdb-design.md) + the two adoption designs ([ScadaBridge](docs/plans/2026-07-19-scadabridge-localdb-design.md), [OtOpcUa](docs/plans/2026-07-20-otopcua-localdb-design.md)) | [`ZB.MOM.WW.LocalDb/`](ZB.MOM.WW.LocalDb/) |
|
||||
|
||||
Each hosted library directory carries its own `CLAUDE.md` with the full version narrative, consumer matrix and
|
||||
@@ -190,13 +190,15 @@ publish; registry `BaseUrl` host rule) and the QA topology note: [`ZB.MOM.WW.Ove
|
||||
> is orthogonal to the deployment tier. No production box has been identified. Full host/port
|
||||
> inventory for both tiers: [`deployment.md`](deployment.md) §2.1 + §7.1.
|
||||
>
|
||||
> ⛔ **Remote command execution on the wonder boxes is DISABLED (2026-09-11).** The `winrm` skill
|
||||
> (WinRM exec/ps, service control) set off the security software and has been moved to
|
||||
> `~/.claude/skills-disabled/winrm`; do not use it or stand up a replacement channel until the user
|
||||
> clears one. **Still allowed:** UNC/SMB file copies to the boxes and logging directly into the SQL
|
||||
> Servers. Any deploy step that needs a command run on a box is blocked until a new channel is found.
|
||||
> ⛔ **Remote command execution on the wonder boxes: DEPLOYMENT ACTIVITIES ONLY (2026-09-28).** The
|
||||
> `winrm` skill was disabled 2026-09-11 (WinRM exec/ps set off the security software) and re-enabled
|
||||
> 2026-09-28 scoped to deploys: remote exec only for the on-box steps of a deploy the user asked for
|
||||
> (preflight, backup, stop/swap/start, migration, post-deploy verify, rollback). No ad-hoc diagnostics,
|
||||
> log grepping or version checks over WinRM — use SMB, HTTP or SQL. **Always allowed:** UNC/SMB file
|
||||
> copies and logging directly into the SQL Servers. Scope rules live in the skill's *Scope* section.
|
||||
>
|
||||
> **Deployed state (2026-09-10):** DEV and both QA nodes run every repo tip — ScadaBridge `2b9a7c3c`,
|
||||
> **Deployed state (2026-09-10; ScadaBridge DEV + QA updated 2026-09-28):** DEV and both QA nodes run every repo tip — ScadaBridge `2b9a7c3c`
|
||||
> (**DEV vd03 now `b3021e7c`**, migrations 57, script `Files` access on `E:\ApiInstall\ScadaBridge\recipes`, plus the in-memory MES/Delmia/alarm test machines SBTEST01/02 — QA vq03/vq04 also `b3021e7c` since 2026-09-28, migrations 57, file access off, `SimpleAlarmStatusRequest` copied from DEV),
|
||||
> OtOpcUa `b8b4c18f`, mxaccessgw `4f931e4` (Server + x86 Worker), HistorianGateway `d8d890c`, Overview
|
||||
> `846b77f`/`818775b3` (code-identical). All driven from the Mac by the `winrm` skill (WinRM 5985 + SMB 445,
|
||||
> nothing installed on the boxes); psbridge and servecli are retired. Per-box records: `deployment.md` §9.
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<!-- 0.3.0: new ZB.MOM.WW.Auth.ApiKeys.SqlServer package — a shared SQL Server API-key store
|
||||
for clustered hosts (e.g. a ScadaBridge central pair), fixing the per-node SQLite store
|
||||
going out of sync on failover. Adds the IApiKeyStoreMigrator abstraction (SQLite
|
||||
implements it unchanged). SQLite stays the default for every existing consumer; no
|
||||
consumer code change is required to move to 0.3.0. -->
|
||||
<Version>0.3.0</Version>
|
||||
<!-- 0.4.0: opt-in LdapOptions.BindMode = User — authenticate by binding as the signing-in user
|
||||
(UserBindTemplate) and read their own groups on the same connection; no service account.
|
||||
ServiceAccount stays the default, so existing consumers need no change. -->
|
||||
<!-- 0.4.1: Username canonicalised from the entry's UserNameAttribute; empty passwords refused
|
||||
in both bind modes (ServiceAccount-mode behaviour change); template docs prefer EXAMPLE\{0}. -->
|
||||
<Version>0.4.1</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -30,7 +30,23 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
|
||||
|
||||
## Versioning
|
||||
|
||||
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.3.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
|
||||
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.1**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
|
||||
|
||||
### 0.4.1 — LDAP review fixes
|
||||
|
||||
- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value.
|
||||
- **Behaviour change for `ServiceAccount`-mode consumers:** an empty (or null) password is now refused by the library as `BadCredentials` before any network call, in both bind modes. Previously only `User` mode refused it; in `ServiceAccount` mode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone.
|
||||
- **Template guidance:** prefer the down-level `UserBindTemplate` `EXAMPLE\{0}` — it maps exactly to the `sAMAccountName` the group search uses. A UPN template (`{0}@example.com`) is only correct where every account's UPN prefix equals its `sAMAccountName`; AD resolves UPN binds against the explicit `userPrincipalName`, so a mismatch can fail sign-in or bind a different account than the one whose groups are read.
|
||||
- No `Abstractions` contract change.
|
||||
|
||||
### 0.4.0 — LDAP sign-in by user bind (opt-in)
|
||||
|
||||
- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `EXAMPLE\{0}` — preferred, see 0.4.1 — or `{0}@example.com`) and **`AcceptDomainQualifiedNames`** (default `true`).
|
||||
- In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated.
|
||||
- Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`.
|
||||
- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling (0.4.1 also canonicalises its case from the entry).
|
||||
- No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged.
|
||||
- `UserBindTemplate` output is used verbatim as the bind name, so use a down-level (`EXAMPLE\{0}`, preferred) or UPN (`{0}@example.com`, only where every UPN prefix equals the `sAMAccountName`) template; a DN template is not escaped.
|
||||
|
||||
### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)
|
||||
|
||||
|
||||
@@ -4,6 +4,20 @@ namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
public enum LdapTransport { Ldaps, StartTls, None }
|
||||
|
||||
/// <summary>How <see cref="LdapOptions"/> authenticates a sign-in.</summary>
|
||||
public enum LdapBindMode
|
||||
{
|
||||
/// <summary>Bind as the service account, search for the user, re-bind as the user (0.3.0 behaviour).</summary>
|
||||
ServiceAccount,
|
||||
|
||||
/// <summary>
|
||||
/// Bind as the signing-in user (identity built from <see cref="LdapOptions.UserBindTemplate"/>);
|
||||
/// a successful bind authenticates them, and the same connection reads their own entry for groups.
|
||||
/// No service account is used.
|
||||
/// </summary>
|
||||
User,
|
||||
}
|
||||
|
||||
public sealed record LdapOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
@@ -19,6 +33,33 @@ public sealed record LdapOptions
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
public int ConnectionTimeoutMs { get; init; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the authentication flow. <see cref="LdapBindMode.ServiceAccount"/> (the default) is the
|
||||
/// 0.3.0 bind-then-search-then-bind flow; <see cref="LdapBindMode.User"/> binds as the user and needs
|
||||
/// no <see cref="ServiceAccountDn"/> / <see cref="ServiceAccountPassword"/>.
|
||||
/// </summary>
|
||||
public LdapBindMode BindMode { get; init; } = LdapBindMode.ServiceAccount;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/> only: the simple-bind identity, with <c>{0}</c> replaced by the
|
||||
/// normalised login name. Must contain <c>{0}</c> exactly once.
|
||||
/// <para>
|
||||
/// Prefer the down-level form <c>EXAMPLE\{0}</c>: it maps exactly to the <c>sAMAccountName</c> the
|
||||
/// group search uses, so the account that binds is the account whose groups are read. A UPN
|
||||
/// template (<c>{0}@example.com</c>) is only correct where every account's UPN prefix equals its
|
||||
/// <c>sAMAccountName</c> — AD resolves a UPN bind against the explicit <c>userPrincipalName</c>, so
|
||||
/// a mismatch can fail sign-in or bind a different account than the one whose groups are read.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string UserBindTemplate { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/> only: when <see langword="true"/> (default) a login typed as
|
||||
/// <c>DOMAIN\name</c> or <c>name@suffix</c> is reduced to <c>name</c>; when <see langword="false"/>
|
||||
/// such input is refused as bad credentials without contacting the directory.
|
||||
/// </summary>
|
||||
public bool AcceptDomainQualifiedNames { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback)
|
||||
/// fails with a system-side error (connect/TLS, service-account bind, or search).
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Reduces a typed login to the bare account name used by <see cref="Abstractions.Ldap.LdapBindMode.User"/>:
|
||||
/// the qualifier is stripped before the name reaches the bind template and the search filter, so audit
|
||||
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next. Case is NOT
|
||||
/// normalised here: the returned username is canonicalised from the directory entry's
|
||||
/// <c>UserNameAttribute</c> value, so <c>JDoe</c> and <c>jdoe</c> also yield one identity; this name is
|
||||
/// only the fallback when the entry has no such value.
|
||||
/// </summary>
|
||||
internal static class LdapUserName
|
||||
{
|
||||
/// <summary>
|
||||
/// Trims <paramref name="input"/> and, when <paramref name="acceptDomainQualified"/>, strips one
|
||||
/// leading <c>DOMAIN\</c> or one trailing <c>@suffix</c>. Returns <see langword="false"/> (with
|
||||
/// <paramref name="name"/> empty) for blank input, for both forms at once, for a repeated
|
||||
/// separator, for an empty side, or for any qualified form when <paramref name="acceptDomainQualified"/>
|
||||
/// is <see langword="false"/>.
|
||||
/// </summary>
|
||||
public static bool TryNormalize(string? input, bool acceptDomainQualified, out string name)
|
||||
{
|
||||
name = "";
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
var s = input.Trim();
|
||||
var slash = s.IndexOf('\\');
|
||||
var at = s.IndexOf('@');
|
||||
|
||||
if (slash >= 0 || at >= 0)
|
||||
{
|
||||
if (!acceptDomainQualified || (slash >= 0 && at >= 0))
|
||||
return false;
|
||||
|
||||
if (slash >= 0)
|
||||
{
|
||||
if (slash == 0 || s.IndexOf('\\', slash + 1) >= 0)
|
||||
return false;
|
||||
s = s[(slash + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unlike the slash branch (whose extracted suffix is checked for emptiness by the
|
||||
// final length check below), the extracted piece here is the *prefix* — so an empty
|
||||
// domain part (a trailing "@", e.g. "jdoe@") must be rejected explicitly.
|
||||
if (at == s.Length - 1 || s.IndexOf('@', at + 1) >= 0)
|
||||
return false;
|
||||
s = s[..at];
|
||||
}
|
||||
|
||||
s = s.Trim();
|
||||
}
|
||||
|
||||
if (s.Length == 0)
|
||||
return false;
|
||||
|
||||
name = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
|
||||
/// is one login starting at a stale endpoint and walking on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// With <c>BindMode = User</c> the service-account bind and the user re-bind are replaced by a
|
||||
/// single bind as the user (<see cref="LdapOptions.UserBindTemplate"/>); see <see cref="CompleteAsUser"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LdapAuthService : ILdapAuthService
|
||||
{
|
||||
@@ -141,6 +145,21 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
// filter and the returned result (avoids two identities for one person).
|
||||
username = username.Trim();
|
||||
|
||||
// 3b. An empty password must never reach the directory, in EITHER bind mode: AD answers a
|
||||
// simple bind with an empty password as an UNAUTHENTICATED bind and reports success —
|
||||
// the user bind (or the ServiceAccount-mode re-bind as the user's DN) would admit anyone.
|
||||
// Refused here, before any network call, so no consumer depends on its own edge check.
|
||||
if (string.IsNullOrEmpty(password))
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
|
||||
// 3c. User-bind mode: reduce the name to the bare account name the bind template and the
|
||||
// search filter share. (The returned username is canonicalised from the entry later.)
|
||||
if (_options.BindMode == LdapBindMode.User
|
||||
&& !LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
|
||||
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
|
||||
var preferred = Volatile.Read(ref _preferredIndex);
|
||||
@@ -235,74 +254,9 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
|
||||
}
|
||||
|
||||
// 6. Service-account bind so we can search for the user's DN. A bind failure
|
||||
// here is a service-account misconfiguration — DISTINCT from a user-credential
|
||||
// failure — so it maps to ServiceAccountBindFailed.
|
||||
try
|
||||
{
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
|
||||
}
|
||||
|
||||
// 7. Search for the user entry by the configured username attribute. A search
|
||||
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
|
||||
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
|
||||
// closest system-side bucket until a DirectoryUnavailable value exists.
|
||||
IReadOnlyList<LdapSearchEntry> entries;
|
||||
try
|
||||
{
|
||||
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
|
||||
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
|
||||
}
|
||||
|
||||
// 8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// Both are authoritative answers from a healthy directory, so they end the walk.
|
||||
// We never attempt a user bind against an ambiguous DN.
|
||||
if (entries.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
||||
if (entries.Count >= 2)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
|
||||
|
||||
var entry = entries[0];
|
||||
|
||||
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure
|
||||
// here is the end user's bad credentials — the directory answered, so this is
|
||||
// returned directly and never retried against another endpoint.
|
||||
try
|
||||
{
|
||||
conn.Bind(entry.Dn, password);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
// 10. Group extraction. Fail closed: an empty/missing group set, or any error while
|
||||
// extracting groups, is a GroupLookupFailed — never a zero-group success. This
|
||||
// too is an answer about the user, not about the endpoint, so it ends the walk.
|
||||
IReadOnlyList<string> groups;
|
||||
try
|
||||
{
|
||||
groups = ExtractGroups(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
// 11. Success — and only here, with a verified password and >= 1 group.
|
||||
var displayName = ExtractDisplayName(entry, username);
|
||||
return LdapAuthResult.Success(username, displayName, groups);
|
||||
return _options.BindMode == LdapBindMode.User
|
||||
? CompleteAsUser(conn, username, password)
|
||||
: CompleteWithServiceAccount(conn, username, password);
|
||||
}
|
||||
catch (StageFailure stage)
|
||||
{
|
||||
@@ -323,6 +277,121 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups.</summary>
|
||||
private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
|
||||
{
|
||||
// 6. Service-account bind so we can search for the user's DN. A bind failure here is a
|
||||
// service-account misconfiguration — DISTINCT from a user-credential failure — so it
|
||||
// maps to ServiceAccountBindFailed.
|
||||
try
|
||||
{
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
|
||||
}
|
||||
|
||||
// 7–8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// Both are authoritative answers from a healthy directory, so they end the walk.
|
||||
// We never attempt a user bind against an ambiguous DN.
|
||||
var entries = SearchUser(conn, username);
|
||||
if (entries.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
||||
if (entries.Count >= 2)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
|
||||
|
||||
var entry = entries[0];
|
||||
|
||||
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure here is
|
||||
// the end user's bad credentials — the directory answered, so this is returned directly
|
||||
// and never retried against another endpoint.
|
||||
try
|
||||
{
|
||||
conn.Bind(entry.Dn, password);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
return SucceedFromEntry(entry, username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/>: the user's own bind is the authentication; the same
|
||||
/// connection then reads their entry. Only result 49 (invalid credentials — AD's answer for a
|
||||
/// wrong password AND for unknown, locked, disabled or expired accounts) is the user's failure
|
||||
/// and final; any other bind error means this endpoint gave no answer, so the walk moves on.
|
||||
/// </summary>
|
||||
private LdapAuthResult CompleteAsUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
|
||||
{
|
||||
var bindName = _options.UserBindTemplate.Replace("{0}", username, StringComparison.Ordinal);
|
||||
try
|
||||
{
|
||||
conn.Bind(bindName, password);
|
||||
}
|
||||
catch (LdapException ex) when (ex.ResultCode == LdapException.InvalidCredentials)
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "user-bind");
|
||||
}
|
||||
|
||||
// After a good bind, anything but exactly one readable entry is a failure to establish the
|
||||
// user's groups — never a success, and never UserNotFound (the directory just accepted them).
|
||||
var entries = SearchUser(conn, username);
|
||||
if (entries.Count != 1)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
return SucceedFromEntry(entries[0], username);
|
||||
}
|
||||
|
||||
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
|
||||
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
|
||||
{
|
||||
// A search failure is infrastructure (directory unreachable / unhealthy) — system-side.
|
||||
// NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps
|
||||
// to ServiceAccountBindFailed, the closest system-side bucket (same limitation as
|
||||
// Connect), until a DirectoryUnavailable value exists.
|
||||
try
|
||||
{
|
||||
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
|
||||
return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steps 10–11: fail-closed group extraction, then success. The returned username is the
|
||||
/// entry's own <see cref="LdapOptions.UserNameAttribute"/> value (the directory's spelling), so
|
||||
/// <c>JDoe</c> and <c>jdoe</c> sign in as one identity; the typed (normalised) name is the
|
||||
/// fallback when the entry carries no such value.
|
||||
/// </summary>
|
||||
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
|
||||
{
|
||||
IReadOnlyList<string> groups;
|
||||
try
|
||||
{
|
||||
groups = ExtractGroups(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
var canonical = ExtractUserName(entry, username);
|
||||
return LdapAuthResult.Success(canonical, ExtractDisplayName(entry, canonical), groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
|
||||
/// out of a stage to the single fail-closed catch site. Never escapes this type.
|
||||
@@ -337,21 +406,42 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
|
||||
public LdapAuthFailure Failure { get; }
|
||||
|
||||
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>search</c>), for diagnostics.</summary>
|
||||
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>user-bind</c> / <c>search</c>), for diagnostics.</summary>
|
||||
public string Stage { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the distinct attribute list requested from the directory. The display-name
|
||||
/// and group attributes are de-duplicated so we never request the same attribute twice
|
||||
/// when an operator configures them to the same value.
|
||||
/// Builds the distinct attribute list requested from the directory: display name, groups and
|
||||
/// the username attribute (read back as the canonical username). They are de-duplicated,
|
||||
/// case-insensitively, so we never request the same attribute twice when an operator
|
||||
/// configures two of them to the same value.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> BuildSearchAttributes()
|
||||
{
|
||||
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
|
||||
return new[] { _options.DisplayNameAttribute };
|
||||
var attributes = new List<string>(3);
|
||||
foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute })
|
||||
{
|
||||
if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||
attributes.Add(name);
|
||||
}
|
||||
|
||||
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first non-blank value of the configured username attribute — the directory's
|
||||
/// canonical spelling — falling back to the (already normalised) typed username.
|
||||
/// </summary>
|
||||
private string ExtractUserName(LdapSearchEntry entry, string username)
|
||||
{
|
||||
if (entry.Attributes.TryGetValue(_options.UserNameAttribute, out var values)
|
||||
&& values.Count > 0
|
||||
&& !string.IsNullOrWhiteSpace(values[0]))
|
||||
{
|
||||
return values[0];
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -18,8 +18,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item>
|
||||
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
|
||||
/// search runs against);</item>
|
||||
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
|
||||
/// would bind anonymously, defeating the search-then-bind authentication flow;</item>
|
||||
/// <item>in <see cref="LdapBindMode.ServiceAccount"/> mode <see cref="LdapOptions.ServiceAccountDn"/>
|
||||
/// must be specified — an empty value would bind anonymously, defeating the search-then-bind
|
||||
/// authentication flow; in <see cref="LdapBindMode.User"/> mode <see cref="LdapOptions.UserBindTemplate"/>
|
||||
/// must contain <c>{0}</c> exactly once instead;</item>
|
||||
/// <item>every <see cref="LdapOptions.FallbackServers"/> entry must parse as
|
||||
/// <c>"host"</c> or <c>"host:port"</c> — a typo'd backup DC must not lie dormant
|
||||
/// until the primary outage it exists to survive.</item>
|
||||
@@ -63,7 +65,20 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
"set it to the search-base DN (e.g. \"dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
if (options.BindMode == LdapBindMode.User)
|
||||
{
|
||||
// User mode binds as the signing-in user, so there is no service account to require.
|
||||
// The template is the bind identity: without exactly one {0} every login would bind as
|
||||
// the same fixed name (or as a name with the user spliced in twice).
|
||||
if (CountPlaceholders(options.UserBindTemplate) != 1)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.UserBindTemplate)} must contain {{0}} exactly once when " +
|
||||
$"{nameof(LdapOptions.BindMode)} is {nameof(LdapBindMode.User)} — e.g. \"{{0}}@example.com\" " +
|
||||
"or \"EXAMPLE\\{0}\".");
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
|
||||
@@ -88,4 +103,16 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
private static int CountPlaceholders(string? template)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template))
|
||||
return 0;
|
||||
|
||||
var count = 0;
|
||||
for (var i = template.IndexOf("{0}", StringComparison.Ordinal); i >= 0;
|
||||
i = template.IndexOf("{0}", i + 3, StringComparison.Ordinal))
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -134,4 +134,30 @@ public class ServiceCollectionExtensionsTests
|
||||
await host.StartAsync();
|
||||
await host.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbLdapAuth_BindsUserBindModeKeys()
|
||||
{
|
||||
IConfiguration config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{LdapSection}:Server"] = LdapServer,
|
||||
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
|
||||
[$"{LdapSection}:Transport"] = nameof(LdapTransport.Ldaps),
|
||||
[$"{LdapSection}:BindMode"] = "User",
|
||||
[$"{LdapSection}:UserBindTemplate"] = "{0}@corp.test",
|
||||
[$"{LdapSection}:AcceptDomainQualifiedNames"] = "false",
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbLdapAuth(config, LdapSection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<LdapOptions>>().Value;
|
||||
|
||||
Assert.Equal(LdapBindMode.User, options.BindMode);
|
||||
Assert.Equal("{0}@corp.test", options.UserBindTemplate);
|
||||
Assert.False(options.AcceptDomainQualifiedNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
private bool _throwOnConnect;
|
||||
private bool _throwOnServiceBind;
|
||||
private bool _throwOnUserBind;
|
||||
private int? _firstBindResultCode;
|
||||
private bool _throwOnSearch;
|
||||
|
||||
// ---- observation -----
|
||||
|
||||
@@ -32,13 +34,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
/// </summary>
|
||||
public int BindAttempts { get; private set; }
|
||||
|
||||
/// <summary>The attribute list passed to the most recent <see cref="Search"/> call (the fake still returns every scripted attribute).</summary>
|
||||
public IReadOnlyList<string>? SearchAttributes { get; private set; }
|
||||
|
||||
/// <summary>Every call in order: <c>connect</c>, <c>bind:<dn></c>, <c>search:<filter></c>.</summary>
|
||||
public List<string> Calls { get; } = new();
|
||||
|
||||
// ---- builder methods -----
|
||||
|
||||
/// <summary>
|
||||
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
|
||||
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>.
|
||||
/// Builds a minimal attribute bag with <c>memberOf</c>, optional <c>displayName</c> and any
|
||||
/// <paramref name="extraAttributes"/> (e.g. <c>sAMAccountName</c>) the test needs.
|
||||
/// </summary>
|
||||
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
|
||||
public FakeLdapConnection WithUserEntry(
|
||||
string dn,
|
||||
string[] memberOf,
|
||||
string? displayName = null,
|
||||
IReadOnlyDictionary<string, string[]>? extraAttributes = null)
|
||||
{
|
||||
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -46,6 +59,11 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
};
|
||||
if (displayName is not null)
|
||||
attrs["displayName"] = new[] { displayName };
|
||||
if (extraAttributes is not null)
|
||||
{
|
||||
foreach (var (name, values) in extraAttributes)
|
||||
attrs[name] = values;
|
||||
}
|
||||
|
||||
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
|
||||
return this;
|
||||
@@ -83,6 +101,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> with <paramref name="resultCode"/> on the
|
||||
/// FIRST bind. In user-bind mode the first bind is the user's own, so 49 simulates a wrong
|
||||
/// password and e.g. 52 (Unavailable) a domain controller that cannot answer.
|
||||
/// </summary>
|
||||
public FakeLdapConnection ThrowOnFirstBind(int resultCode)
|
||||
{
|
||||
_firstBindResultCode = resultCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Search"/> (unhealthy directory).</summary>
|
||||
public FakeLdapConnection ThrowOnSearch()
|
||||
{
|
||||
_throwOnSearch = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
|
||||
/// simulate an unreachable directory (infrastructure failure).
|
||||
@@ -120,6 +156,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
int timeoutMs,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||
{
|
||||
Calls.Add("connect");
|
||||
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
|
||||
ConnectCertCallback = serverCertificateValidationCallback;
|
||||
if (_throwOnConnect)
|
||||
@@ -130,8 +167,12 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
public void Bind(string dn, string password)
|
||||
{
|
||||
BindAttempts++;
|
||||
Calls.Add($"bind:{dn}");
|
||||
var isServiceBind = BindAttempts == 1;
|
||||
|
||||
if (isServiceBind && _firstBindResultCode is { } code)
|
||||
throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn);
|
||||
|
||||
if ((_throwOnServiceBind && isServiceBind)
|
||||
|| (_throwOnUserBind && !isServiceBind)
|
||||
|| _throwBindDns.Contains(dn))
|
||||
@@ -147,7 +188,14 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
string searchBase,
|
||||
string filter,
|
||||
IReadOnlyList<string> attributes)
|
||||
=> _scriptedEntries.AsReadOnly();
|
||||
{
|
||||
Calls.Add($"search:{filter}");
|
||||
SearchAttributes = attributes;
|
||||
if (_throwOnSearch)
|
||||
throw new Novell.Directory.Ldap.LdapException(
|
||||
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
|
||||
return _scriptedEntries.AsReadOnly();
|
||||
}
|
||||
|
||||
public void Dispose() { /* nothing to clean up */ }
|
||||
}
|
||||
|
||||
@@ -147,4 +147,45 @@ public class LdapAuthServiceTests
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode()
|
||||
{
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice",
|
||||
new Dictionary<string, string[]> { ["cn"] = new[] { "alice" } });
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
|
||||
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal("alice", r.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent()
|
||||
{
|
||||
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice");
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
|
||||
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal("ALICE", r.Username);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password)
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"));
|
||||
var svc = new LdapAuthService(Opts(), factory);
|
||||
|
||||
var r = await svc.AuthenticateAsync("alice", password!, default);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/>: bind as the signing-in user, then read their own entry on the
|
||||
/// same connection. The service account in <see cref="Opts"/> is deliberately populated so the
|
||||
/// tests prove it is never used.
|
||||
/// </summary>
|
||||
public class LdapAuthServiceUserBindTests
|
||||
{
|
||||
private static LdapOptions Opts(params string[] fallbacks) => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "dc1",
|
||||
Port = 636,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
SearchBase = "dc=x",
|
||||
UserNameAttribute = "sAMAccountName",
|
||||
DisplayNameAttribute = "displayName",
|
||||
GroupAttribute = "memberOf",
|
||||
BindMode = LdapBindMode.User,
|
||||
UserBindTemplate = "{0}@corp.test",
|
||||
ServiceAccountDn = "cn=svc,dc=x",
|
||||
ServiceAccountPassword = "svcpw",
|
||||
FallbackServers = fallbacks,
|
||||
};
|
||||
|
||||
private static FakeLdapConnection Jane() => new FakeLdapConnection()
|
||||
.WithUserEntry("CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
|
||||
|
||||
private static Task<LdapAuthResult> Login(LdapAuthService svc, string user, string pw = "pw") =>
|
||||
svc.AuthenticateAsync(user, pw, CancellationToken.None);
|
||||
|
||||
[Fact]
|
||||
public async Task Success_BindsAsUser_ThenSearchesOnTheSameConnection()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("jdoe", result.Username);
|
||||
Assert.Equal("Jane Doe", result.DisplayName);
|
||||
Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups);
|
||||
var conn = Assert.Single(factory.Created);
|
||||
Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "search:(sAMAccountName=jdoe)" }, conn.Calls);
|
||||
Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"NAM\jdoe")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
[InlineData(" jdoe ")]
|
||||
public async Task QualifiedNames_BindAsTheTemplate_AndReturnTheBareName(string typed)
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), typed);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("jdoe", result.Username);
|
||||
Assert.Equal(new[] { "jdoe@corp.test" }, factory.Created[0].BoundDns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyPassword_IsRefused_WithoutContactingTheDirectory()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe", "");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("@x")]
|
||||
[InlineData(@"a\b\c")]
|
||||
[InlineData("a@b@c")]
|
||||
public async Task UnusableNames_AreRefused_WithoutContactingTheDirectory(string typed)
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), typed);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrictMode_RefusesQualifiedNames_WithoutContactingTheDirectory()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var svc = new LdapAuthService(Opts() with { AcceptDomainQualifiedNames = false }, factory);
|
||||
|
||||
var result = await Login(svc, @"NAM\jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongPassword_IsFinal_AndNeverAsksTheFallback()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
Jane().ThrowOnFirstBind(LdapException.InvalidCredentials), Jane());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe", "wrong");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonCredentialBindError_FailsOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
Jane().ThrowOnFirstBind(LdapException.Unavailable), Jane());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectFailure_FailsOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().ThrowOnConnect(), Jane());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFailure_FailsOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane().ThrowOnSearch(), Jane());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryEndpointDown_IsSystemSide()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(), new FakeLdapConnection().ThrowOnConnect());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithNoMatch());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithDuplicateMatch());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoGroups_IsGroupLookupFailed()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().WithUserEntry("CN=Jane,DC=x", Array.Empty<string>(), "Jane"));
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry(
|
||||
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
|
||||
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } }));
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("jdoe", result.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "JDOE");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("JDOE", result.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_RequestsTheUserNameAttribute()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||
|
||||
Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory);
|
||||
await Login(svc, "jdoe");
|
||||
|
||||
Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes);
|
||||
}
|
||||
}
|
||||
@@ -116,4 +116,38 @@ public class LdapOptionsValidatorTests
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
|
||||
.Failed);
|
||||
|
||||
private static LdapOptions UserOpts() => Opts() with
|
||||
{
|
||||
BindMode = LdapBindMode.User,
|
||||
UserBindTemplate = "{0}@corp.test",
|
||||
ServiceAccountDn = "",
|
||||
ServiceAccountPassword = "",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Defaults_KeepServiceAccountMode()
|
||||
{
|
||||
var o = new LdapOptions();
|
||||
Assert.Equal(LdapBindMode.ServiceAccount, o.BindMode);
|
||||
Assert.Equal("", o.UserBindTemplate);
|
||||
Assert.True(o.AcceptDomainQualifiedNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_UserMode_DoesNotRequireServiceAccount() =>
|
||||
Assert.True(new LdapOptionsValidator().Validate(null, UserOpts()).Succeeded);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
[InlineData("{0}{0}@corp.test")]
|
||||
public void Validator_UserMode_RequiresExactlyOnePlaceholder(string template)
|
||||
{
|
||||
var result = new LdapOptionsValidator().Validate(null, UserOpts() with { UserBindTemplate = template });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(nameof(LdapOptions.UserBindTemplate), result.FailureMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
public class LdapUserNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("jdoe")]
|
||||
[InlineData(" jdoe ")]
|
||||
[InlineData(@"NAM\jdoe")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
public void Accepting_ReducesToBareName(string input)
|
||||
{
|
||||
Assert.True(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
|
||||
Assert.Equal("jdoe", name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"NAM\jdoe")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
public void Strict_RefusesQualifiedNames(string input) =>
|
||||
Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: false, out _));
|
||||
|
||||
[Fact]
|
||||
public void Strict_AcceptsBareName()
|
||||
{
|
||||
Assert.True(LdapUserName.TryNormalize(" jdoe ", acceptDomainQualified: false, out var name));
|
||||
Assert.Equal("jdoe", name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("@x")]
|
||||
[InlineData(@"\jdoe")]
|
||||
[InlineData("jdoe@")]
|
||||
[InlineData(@"NAM\")]
|
||||
[InlineData(@"a\b\c")]
|
||||
[InlineData("a@b@c")]
|
||||
[InlineData(@"NAM\jdoe@corp.test")]
|
||||
public void Unusable_IsRefused(string? input)
|
||||
{
|
||||
Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
|
||||
Assert.Equal("", name);
|
||||
}
|
||||
}
|
||||
@@ -54,3 +54,12 @@ cd docker && docker compose up -d --build # local rigs → http://localhost:5320
|
||||
# wonder deploy artifact — SELF-CONTAINED, the box has no .NET 10 runtime:
|
||||
dotnet publish src/ZB.MOM.WW.Overview/ZB.MOM.WW.Overview.csproj -c Release -r win-x64 --self-contained true -o <out>
|
||||
```
|
||||
|
||||
**2026-10-01 — BTDB Manager registered on all three dashboards.** `appsettings.Wonder.json` (vd03,
|
||||
committed) gains one instance `wonder` → `http://127.0.0.1:9876` (management link
|
||||
`http://10.220.157.247:9876/`); the QA overlay `E:\ApiInstall\Overview\app\appsettings.QA.json` (deployed
|
||||
only, identical on vq03/vq04) gains `vq03`/`vq04` → `http://wonder-app-vq0N.zmr.zimmer.com:9876`. All
|
||||
`HasActiveRole: false` (single unclustered Blazor app); `/health/ready` carries one `sql` entry (BT
|
||||
reachable). The registry is read at startup only, so `ZbOverview` was restarted on each node (previous
|
||||
files kept as `*.bak-20261001T02xxxx`). Verified: vd03 6/6 Up, vq03 and vq04 12/12 Up.
|
||||
|
||||
|
||||
@@ -150,6 +150,21 @@
|
||||
"TimeoutSeconds": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "BTDB Manager",
|
||||
"ManagementLabel": "Manager",
|
||||
"Instances": [
|
||||
{
|
||||
// HasActiveRole false: a single, unclustered Blazor app — no active/standby role.
|
||||
// /health/ready carries one "sql" entry (BT on the DEV SQL box reachable) and is 503
|
||||
// whenever that database is not; /health/active is an empty check set.
|
||||
"Name": "wonder",
|
||||
"BaseUrl": "http://127.0.0.1:9876",
|
||||
"ManagementUrl": "http://10.220.157.247:9876/",
|
||||
"HasActiveRole": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,27 @@ follows [Keep a Changelog](https://keepachangelog.com/); versions are shared acr
|
||||
package in the family (see `Directory.Build.props`) even when a given release only touches
|
||||
one of them.
|
||||
|
||||
## 0.6.3 — 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- **gRPC hub follower sweeps are observable.** `GrpcSecretSyncService` (package
|
||||
`ZB.MOM.WW.Secrets.Replicator.Grpc`) now logs every sweep outcome instead of only the sweeps
|
||||
that pull a change:
|
||||
- `EventId 4` (Debug) — a sweep found nothing new; logged on every in-sync sweep.
|
||||
- `EventId 5` (Information) — an in-sync heartbeat, at most once per
|
||||
`SecretsGrpcHubClientOptions.InSyncLogInterval` (new, default 1 hour, validated positive), so
|
||||
"the loop is alive and still agrees with the hub" is visible at the default log level without
|
||||
a line every 30 seconds.
|
||||
- `EventId 6` (Information) — the first successful sweep after one or more failures, naming
|
||||
the failure count.
|
||||
- **`ISecretsHubSyncStatus`** — a public, DI-registered read model of the follower loop:
|
||||
`LastSweepUtc`, `LastSuccessUtc`, `ConsecutiveFailures`, `LastError`. Getters are synchronized
|
||||
with the writer, so a health check or status page reads a consistent snapshot.
|
||||
|
||||
No behaviour change to syncing itself, and no wire or schema change. Only hub followers gain
|
||||
anything from this release; other consumers can stay on 0.6.2.
|
||||
|
||||
## 0.6.2 — 2026-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.6.2</Version>
|
||||
<Version>0.6.3</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
+9
@@ -150,6 +150,15 @@ public static class SecretsGrpcHubClientExtensions
|
||||
});
|
||||
}
|
||||
|
||||
// Singleton, and registered for BOTH the concrete type and the public interface: the
|
||||
// service needs the concrete type (it calls the internal RecordSuccess/RecordFailure), and
|
||||
// an application observing hub-sync health (a readiness check, a status page) needs only
|
||||
// the read-only ISecretsHubSyncStatus. TryAddSingleton for the concrete type means the
|
||||
// interface registration below resolves the SAME instance rather than constructing a second
|
||||
// one, which would silently split "what the service records" from "what a consumer reads".
|
||||
services.TryAddSingleton<SecretsHubSyncStatus>();
|
||||
services.TryAddSingleton<ISecretsHubSyncStatus>(sp => sp.GetRequiredService<SecretsHubSyncStatus>());
|
||||
|
||||
services.AddHostedService<GrpcSecretSyncService>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -36,6 +36,13 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
private readonly SecretsGrpcHubClientOptions _options;
|
||||
private readonly ILogger<GrpcSecretSyncService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SecretsHubSyncStatus _status;
|
||||
|
||||
// Last time a row actually moved, and last time the in-sync heartbeat was logged — both
|
||||
// instance state because they describe THIS service's observed history, not anything the hub
|
||||
// or the local store can answer on demand.
|
||||
private DateTimeOffset? _lastPullUtc;
|
||||
private DateTimeOffset? _lastInSyncHeartbeatUtc;
|
||||
|
||||
/// <summary>Creates the sync service.</summary>
|
||||
/// <param name="local">The node's local store — the only thing this service writes to.</param>
|
||||
@@ -44,13 +51,20 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
/// <param name="options">Hub-client options.</param>
|
||||
/// <param name="logger">Logger for sweep outcomes and failures.</param>
|
||||
/// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param>
|
||||
/// <param name="status">
|
||||
/// The sweep-outcome tracker this service updates. Optional so existing callers that construct
|
||||
/// this type directly (tests, mainly) keep compiling; a real host resolves the same singleton
|
||||
/// <c>AddZbSecretsGrpcHubClient</c> registers for <see cref="ISecretsHubSyncStatus"/>, so the two
|
||||
/// stay the same instance.
|
||||
/// </param>
|
||||
public GrpcSecretSyncService(
|
||||
ISecretStore local,
|
||||
ISecretsHubReader hub,
|
||||
ISecretCacheInvalidator? cacheInvalidator,
|
||||
IOptions<SecretsGrpcHubClientOptions> options,
|
||||
ILogger<GrpcSecretSyncService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
SecretsHubSyncStatus? status = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
@@ -59,6 +73,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
_options = options.Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_status = status ?? new SecretsHubSyncStatus();
|
||||
_reconciler = new SecretReplicationReconciler(
|
||||
local,
|
||||
cacheInvalidator,
|
||||
@@ -82,11 +97,22 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one pull-only reconcile against the hub.
|
||||
/// Runs one pull-only reconcile against the hub. Throws on any failure — an unreachable hub, a
|
||||
/// rejected credential, a deadline — so a direct caller sees the raw outcome. The background
|
||||
/// loop never calls this directly; it goes through <see cref="SweepAndRecordAsync"/>, which
|
||||
/// shares this method's reconcile core but also records the outcome and logs it.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the sweep.</param>
|
||||
/// <returns>How many rows were pulled from the hub.</returns>
|
||||
internal async Task<int> SweepAsync(CancellationToken ct)
|
||||
internal async Task<int> SweepAsync(CancellationToken ct) =>
|
||||
(await SweepCoreAsync(ct).ConfigureAwait(false)).Pulled;
|
||||
|
||||
/// <summary>
|
||||
/// The reconcile core shared by <see cref="SweepAsync"/> and <see cref="SweepAndRecordAsync"/>:
|
||||
/// fetches the hub manifest and reconciles the local store against it. Throws on failure —
|
||||
/// callers that want failures recorded/logged instead do that around this call, not inside it.
|
||||
/// </summary>
|
||||
private async Task<(int Pulled, int ManifestCount)> SweepCoreAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<SecretManifestEntry> hubManifest =
|
||||
await _hub.GetManifestAsync(ct).ConfigureAwait(false);
|
||||
@@ -97,20 +123,54 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
// it rather than re-deriving the diff here also keeps the batching: a cold-starting node's
|
||||
// pull set is central's ENTIRE inventory, and an unchunked GetSecrets would exceed gRPC's
|
||||
// 4 MB default message cap and then fail identically on every interval forever.
|
||||
return await _reconciler
|
||||
int pulled = await _reconciler
|
||||
.ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return (pulled, hubManifest.Count);
|
||||
}
|
||||
|
||||
private async Task SweepSafelyAsync(CancellationToken ct)
|
||||
private Task SweepSafelyAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// Test-only entry point for the same outcome-recording sweep the background loop runs — never
|
||||
/// throws, always updates <see cref="ISecretsHubSyncStatus"/>, and logs exactly as a real tick
|
||||
/// would. Exists so a test can drive one sweep at a time without standing up the
|
||||
/// <see cref="BackgroundService"/> host lifecycle.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the sweep.</param>
|
||||
internal Task SweepForTestAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
|
||||
|
||||
private async Task SweepAndRecordAsync(CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
int pulled = await SweepAsync(ct).ConfigureAwait(false);
|
||||
(int pulled, int manifestCount) = await SweepCoreAsync(ct).ConfigureAwait(false);
|
||||
|
||||
int priorFailures = _status.RecordSuccess(now);
|
||||
|
||||
if (priorFailures > 0)
|
||||
{
|
||||
LogSweepRecovered(priorFailures);
|
||||
}
|
||||
|
||||
if (pulled > 0)
|
||||
{
|
||||
LogSweepConverged(pulled);
|
||||
_lastPullUtc = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogSweepInSync(manifestCount);
|
||||
|
||||
if (_lastInSyncHeartbeatUtc is null ||
|
||||
now - _lastInSyncHeartbeatUtc.Value >= _options.InSyncLogInterval)
|
||||
{
|
||||
LogSweepInSyncHeartbeat(manifestCount, _lastPullUtc);
|
||||
_lastInSyncHeartbeatUtc = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
@@ -124,6 +184,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
// has the same correct response: keep serving the local store and try again. Letting any
|
||||
// of it escape would fault the BackgroundService and take the host down over a transient
|
||||
// central outage, which is precisely the availability the pull-only topology exists for.
|
||||
_status.RecordFailure(now, ex);
|
||||
LogSweepFailed(ex);
|
||||
}
|
||||
}
|
||||
@@ -145,4 +206,36 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
|
||||
private partial void LogRowDiscarded(Exception exception, string secretName);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Debug on every sweep that found nothing to pull — the full-cadence signal, for a
|
||||
/// verbose log level, that the loop is alive and agrees with the hub even between the rare
|
||||
/// Information-level heartbeats.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 4,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Secrets hub sweep found nothing new; {Count} secret(s) already in sync.")]
|
||||
private partial void LogSweepInSync(int count);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Information at most once per <see cref="SecretsGrpcHubClientOptions.InSyncLogInterval"/>
|
||||
/// — an in-sync sweep is otherwise Debug-only, so this is the periodic proof at the default log
|
||||
/// level that the follower is still converged rather than silently stuck.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 5,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Secrets hub in sync ({Count} secret(s)); last change pulled {LastPullUtc}")]
|
||||
private partial void LogSweepInSyncHeartbeat(int count, DateTimeOffset? lastPullUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Information the first time a sweep succeeds after one or more failures — the
|
||||
/// recovery line that closes out whatever run of <see cref="LogSweepFailed"/> Warnings preceded it.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 6,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Secrets hub sync recovered after {Failures} failed sweep(s)")]
|
||||
private partial void LogSweepRecovered(int failures);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
|
||||
/// <summary>Point-in-time outcome of the hub follower's sweeps. Never carries secret material.</summary>
|
||||
/// <remarks>
|
||||
/// Registered as a singleton by <c>AddZbSecretsGrpcHubClient</c> alongside the background sweep
|
||||
/// service, so a host can surface hub-sync health (a readiness check, a status page) without
|
||||
/// scraping logs. Every field reflects the outcome of the most recently completed sweep only —
|
||||
/// there is deliberately no history here, just "what happened last time".
|
||||
/// </remarks>
|
||||
public interface ISecretsHubSyncStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// When the most recent sweep finished, whether it succeeded or failed. <see langword="null"/>
|
||||
/// before the first sweep has completed.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastSweepUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the most recent <em>successful</em> sweep finished. <see langword="null"/> if no sweep
|
||||
/// has ever succeeded.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastSuccessUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of sweeps that have failed in a row, ending at the most recent sweep. Reset to
|
||||
/// zero the moment a sweep succeeds.
|
||||
/// </summary>
|
||||
int ConsecutiveFailures { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The message from the most recent sweep failure, or <see langword="null"/> if the most recent
|
||||
/// sweep succeeded (or none has run yet). This is the exception's type and message only — never
|
||||
/// a stack trace, and never secret material, since the sweep never has plaintext in scope.
|
||||
/// </summary>
|
||||
string? LastError { get; }
|
||||
}
|
||||
|
||||
/// <summary>The mutable implementation the sync service writes to; consumers see only the interface.</summary>
|
||||
/// <remarks>
|
||||
/// Writes (<see cref="RecordSuccess"/>/<see cref="RecordFailure"/>) run under <see cref="_gate"/>,
|
||||
/// and every getter takes the same lock rather than reading its backing field bare — a plain field
|
||||
/// read races the writer with no synchronization otherwise, and a torn/half-written
|
||||
/// <see cref="DateTimeOffset"/> (a multi-field struct) or an interleaving that pairs one write's
|
||||
/// <c>LastSweepUtc</c> with another write's <c>LastError</c> is exactly the inconsistency a status
|
||||
/// consumer must not see.
|
||||
/// </remarks>
|
||||
internal sealed class SecretsHubSyncStatus : ISecretsHubSyncStatus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
|
||||
private DateTimeOffset? _lastSweepUtc;
|
||||
private DateTimeOffset? _lastSuccessUtc;
|
||||
private int _consecutiveFailures;
|
||||
private string? _lastError;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset? LastSweepUtc
|
||||
{
|
||||
get { lock (_gate) { return _lastSweepUtc; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset? LastSuccessUtc
|
||||
{
|
||||
get { lock (_gate) { return _lastSuccessUtc; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int ConsecutiveFailures
|
||||
{
|
||||
get { lock (_gate) { return _consecutiveFailures; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? LastError
|
||||
{
|
||||
get { lock (_gate) { return _lastError; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful sweep, resetting the failure streak.
|
||||
/// </summary>
|
||||
/// <param name="now">The instant the sweep completed.</param>
|
||||
/// <returns>
|
||||
/// The consecutive-failure count <em>before</em> this success — the caller uses a non-zero
|
||||
/// result to decide whether the sweep is a recovery worth logging.
|
||||
/// </returns>
|
||||
internal int RecordSuccess(DateTimeOffset now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
int prior = _consecutiveFailures;
|
||||
_lastSweepUtc = _lastSuccessUtc = now;
|
||||
_consecutiveFailures = 0;
|
||||
_lastError = null;
|
||||
return prior;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records a failed sweep, extending the failure streak.</summary>
|
||||
/// <param name="now">The instant the sweep failed.</param>
|
||||
/// <param name="ex">The exception the sweep failed with.</param>
|
||||
internal void RecordFailure(DateTimeOffset now, Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lastSweepUtc = now;
|
||||
_consecutiveFailures++;
|
||||
_lastError = ex.GetType().Name + ": " + ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -77,6 +77,20 @@ public sealed class SecretsGrpcHubClientOptions
|
||||
/// </summary>
|
||||
public TimeSpan CallDeadline { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How often an in-sync sweep (nothing to pull) is allowed to log an Information-level
|
||||
/// heartbeat. Defaults to one hour. Must be positive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A converged follower sweeps every <see cref="SyncInterval"/> — 30 seconds by default — and
|
||||
/// finds nothing new almost every time. Logging that outcome at Information on every sweep
|
||||
/// would drown the log; staying silent forever would make "the sweep loop is alive and the hub
|
||||
/// still agrees with this node" unobservable between the rare sweeps that actually pull
|
||||
/// something. Every sweep still logs at Debug regardless of this interval, so a verbose log
|
||||
/// level shows the true cadence when it is needed.
|
||||
/// </remarks>
|
||||
public TimeSpan InSyncLogInterval { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the options are unusable. Called by the DI extension at registration time so a
|
||||
/// misconfigured node fails at startup rather than on the first silent sweep failure.
|
||||
@@ -161,6 +175,13 @@ public sealed class SecretsGrpcHubClientOptions
|
||||
$"gRPC secrets hub client CallDeadline must be positive " +
|
||||
$"({sectionPath}:CallDeadline, was {CallDeadline}).");
|
||||
}
|
||||
|
||||
if (InSyncLogInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client InSyncLogInterval must be positive " +
|
||||
$"({sectionPath}:InSyncLogInterval, was {InSyncLogInterval}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The configuration section these options are bound from unless a caller overrides it.</summary>
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the hub follower's sweep-outcome observability: the Debug-per-sweep / Information-
|
||||
/// heartbeat-once-per-interval pair for an in-sync sweep (EventId 4/5), the recovered line the
|
||||
/// first success after a run of failures logs (EventId 6), and <see cref="ISecretsHubSyncStatus"/>
|
||||
/// tracking the same outcomes.
|
||||
/// </summary>
|
||||
public sealed class GrpcSecretSyncServiceLoggingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InSyncSweep_LogsDebugEveryTime_AndInformationHeartbeatOncePerInterval()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-09-28T00:00:00Z"));
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, _) = Harness.Create(
|
||||
time, inSyncLogInterval: TimeSpan.FromHours(1), manifestUnchanged: true);
|
||||
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
time.Advance(TimeSpan.FromMinutes(30));
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
time.Advance(TimeSpan.FromMinutes(31));
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, logs.Entries.Count(l => l.EventId.Id == 4 && l.Level == LogLevel.Debug));
|
||||
Assert.Equal(2, logs.Entries.Count(l => l.EventId.Id == 5 && l.Level == LogLevel.Information));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstSuccessAfterFailures_LogsRecovered_AndStatusResets()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, failFirst: 2);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.Equal(2, status.ConsecutiveFailures);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.Contains(logs.Entries, l => l.EventId.Id == 6 && l.Message.Contains("after 2 failed"));
|
||||
Assert.Equal(0, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastSuccessUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_sweep_never_logs_EventId6_and_status_records_the_error()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, failFirst: 1);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
|
||||
Assert.Equal(1, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastError);
|
||||
Assert.Null(status.LastSuccessUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_in_sync_sweep_never_logs_the_recovered_line()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, manifestUnchanged: true);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
|
||||
Assert.Equal(0, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastSweepUtc);
|
||||
}
|
||||
|
||||
// Assembles a GrpcSecretSyncService wired to a controllable fake hub, a no-op local store (the
|
||||
// scenarios here never actually apply a row — an empty hub manifest means the reconciler's pull
|
||||
// set is always empty), a log-capturing logger, and the internal status tracker the service
|
||||
// writes to — so a test can both drive sweeps and observe everything they produced.
|
||||
private static class Harness
|
||||
{
|
||||
public static (GrpcSecretSyncService Service, RecordingListLogger Logs, ISecretsHubSyncStatus Status) Create(
|
||||
FakeTimeProvider time,
|
||||
TimeSpan? inSyncLogInterval = null,
|
||||
bool manifestUnchanged = false,
|
||||
int failFirst = 0)
|
||||
{
|
||||
var logger = new RecordingListLogger();
|
||||
var status = new SecretsHubSyncStatus();
|
||||
|
||||
ISecretsHubReader hub = failFirst > 0
|
||||
? new SequencedFailThenEmptyHubReader(failFirst)
|
||||
: new EmptyHubReader();
|
||||
|
||||
IOptions<SecretsGrpcHubClientOptions> options = Options.Create(new SecretsGrpcHubClientOptions
|
||||
{
|
||||
Endpoint = "https://central:5001",
|
||||
BearerToken = "unused-by-the-fake",
|
||||
SyncInterval = TimeSpan.FromHours(1),
|
||||
InSyncLogInterval = inSyncLogInterval ?? TimeSpan.FromHours(1),
|
||||
});
|
||||
|
||||
// manifestUnchanged has no separate fake to select: both readers above always answer an
|
||||
// empty (therefore unchanged, nothing-to-pull) manifest once they are done failing, so
|
||||
// the parameter exists to make each call site self-documenting rather than to branch on.
|
||||
var svc = new GrpcSecretSyncService(
|
||||
new NoOpLocalStore(),
|
||||
hub,
|
||||
cacheInvalidator: null,
|
||||
options,
|
||||
logger,
|
||||
time,
|
||||
status);
|
||||
|
||||
return (svc, logger, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A hub with nothing in it — every sweep is in-sync with zero rows to pull.</summary>
|
||||
private sealed class EmptyHubReader : ISecretsHubReader
|
||||
{
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
|
||||
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails the manifest call with a transport-shaped exception for the first
|
||||
/// <paramref name="failuresBeforeSuccess"/> calls, then behaves exactly like
|
||||
/// <see cref="EmptyHubReader"/> forever after.
|
||||
/// </summary>
|
||||
private sealed class SequencedFailThenEmptyHubReader(int failuresBeforeSuccess) : ISecretsHubReader
|
||||
{
|
||||
private int _remaining = failuresBeforeSuccess;
|
||||
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remaining > 0)
|
||||
{
|
||||
_remaining--;
|
||||
return Task.FromException<IReadOnlyList<SecretManifestEntry>>(
|
||||
new InvalidOperationException("hub unreachable"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A local store that is never actually exercised by these scenarios (the hub manifest is
|
||||
/// always empty, so the reconciler's pull set is always empty and none of these members are
|
||||
/// called) beyond <see cref="GetManifestAsync"/>, which the reconciler always calls to compute
|
||||
/// the pull set.
|
||||
/// </summary>
|
||||
private sealed class NoOpLocalStore : ISecretStore
|
||||
{
|
||||
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task UpsertAsync(StoredSecret row, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
|
||||
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<bool> ApplyRewrapAsync(
|
||||
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A clock a test advances by hand, so an hourly heartbeat can be tested without waiting an hour.</summary>
|
||||
/// <remarks>
|
||||
/// Hand-rolled rather than taken from <c>Microsoft.Extensions.TimeProvider.Testing</c>, matching
|
||||
/// this project's existing convention (see <c>HubFakeTimeProvider</c> in
|
||||
/// <c>SecretsHubAuthInterceptorTests.cs</c>) of not taking that package dependency for one small type.
|
||||
/// </remarks>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
/// <summary>Creates a clock starting at the current wall-clock time.</summary>
|
||||
public FakeTimeProvider() : this(DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates a clock starting at <paramref name="now"/>.</summary>
|
||||
/// <param name="now">The instant the clock starts at.</param>
|
||||
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
/// <summary>Moves the clock forward by <paramref name="delta"/>.</summary>
|
||||
/// <param name="delta">The amount of time to advance.</param>
|
||||
public void Advance(TimeSpan delta) => _now += delta;
|
||||
}
|
||||
|
||||
/// <summary>Captures every log entry's level, event id, and formatted message, across all levels.</summary>
|
||||
/// <remarks>
|
||||
/// Distinct from the existing <c>RecordingLogger<T></c> in <c>TestDoubles.cs</c>, which only
|
||||
/// captures Information/Warning and neither type captures <see cref="EventId"/> — this suite needs
|
||||
/// Debug-level entries and event-id assertions that the existing double does not support, and
|
||||
/// widening it would risk the tests that already depend on its narrower shape.
|
||||
/// </remarks>
|
||||
internal sealed class RecordingListLogger : ILogger<GrpcSecretSyncService>
|
||||
{
|
||||
/// <summary>Every entry logged, in order.</summary>
|
||||
public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(formatter);
|
||||
|
||||
Entries.Add((logLevel, eventId, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -291,11 +291,16 @@ public sealed class GrpcSecretSyncServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_quiet_sweep_logs_nothing_at_information()
|
||||
public async Task A_quiet_sweep_logs_the_in_sync_heartbeat_once_but_no_warning()
|
||||
{
|
||||
// A quiet (nothing-to-pull) sweep no longer logs NOTHING at Information: the first-ever
|
||||
// in-sync sweep always logs the once-per-InSyncLogInterval heartbeat (EventId 5), because
|
||||
// "never logged before" is one of the two conditions that fires it. See
|
||||
// GrpcSecretSyncServiceLoggingTests for the interval-gated repeat behaviour on later sweeps.
|
||||
// What a quiet sweep still never does is warn.
|
||||
await RunOneStartupSweepAsync(() => _hub.ManifestCallCount > 0);
|
||||
|
||||
Assert.Equal(0, _logger.InformationCount);
|
||||
Assert.Equal(1, _logger.InformationCount);
|
||||
Assert.Equal(0, _logger.WarningCount);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.4.1</Version>
|
||||
<Version>0.4.2</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -225,7 +225,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
Add .rise to top-level sections; stagger with inline animation-delay
|
||||
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.rise { animation: rise 0.4s ease both; }
|
||||
.rise { animation: rise 0.4s ease backwards; /* backwards: end keyframe equals natural style, preventing a containing-block trap for position:fixed descendants */ }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
COMPONENT LIBRARY
|
||||
|
||||
+2
-2
@@ -380,7 +380,7 @@ each was established:
|
||||
| 8082 / 8083 | ✅ | ✅ | scan | ScadaBridge Site Akka / gRPC control plane (h2c — rejects an HTTP/1.1 GET) |
|
||||
| 5120 / 5130 | ✅ | ✅ | scan | **MxAccessGateway** gRPC / dashboard (VQ04 added 2026-08-14, cloned from VQ03) |
|
||||
| 5220 / 5221 | ✅ | ✅ | scan | **HistorianGateway** dashboard / gRPC h2c (VQ04 added 2026-08-14) |
|
||||
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same 10-instance registry) |
|
||||
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same registry — 12 instances since 2026-10-01, BTDB Manager added) |
|
||||
| 1433 | ✅ | ✗ | scan | SQL Server (**ZB Galaxy repo DB — VQ03 only**; VQ04's gateways point their Galaxy connection strings here) |
|
||||
| 3893 | ✅ | ✅ | svc | glauth LDAP (`ScadaBridge-LDAP` service, loopback-only, `dc=scadabridge,dc=local` — one per box, configs identical) |
|
||||
| **5985** | ✅ | ✅ | live | **WinRM** (HTTP/NTLM; 5986 refused) — **the supported access path since 2026-09-10** via the `winrm` skill; see §2.1 |
|
||||
@@ -399,7 +399,7 @@ nodes, verified Healthy at setup):
|
||||
| ScadaBridge `site` | site-a — **Active** | site-b — Standby |
|
||||
| MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) |
|
||||
| HistorianGateway | Up | Up (since 2026-08-14) |
|
||||
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 10-instance registry) |
|
||||
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 12-instance registry) |
|
||||
|
||||
**Deployed builds on QA (2026-09-10 round, all via the `winrm` skill, both nodes):** ScadaBridge
|
||||
`2b9a7c3c` (all four nodes stopped together, one shared-DB migration 53→55, 2 min 35 s window,
|
||||
|
||||
Reference in New Issue
Block a user