Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 129e47e541 | |||
| 1d6858939d | |||
| de67b45d04 | |||
| 3d991d2160 | |||
| 05667169eb | |||
| 9760497d66 | |||
| 5b153dac74 | |||
| 41e86481e2 |
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@ Repo rules that bind every entry: docs change in the same commit as the source (
|
||||
| SEC-33 | Low | P1 | M | — (co-locate SEC-23) | Done | Any-platform path-rooting acceptance re-opens SEC-01 on Unix; Galaxy `SnapshotCachePath` unvalidated |
|
||||
| SEC-34 | Low | P2 | S | — | Done | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
|
||||
| SEC-35 | Info | — | S | — | N/A (doc-only note discharged 2026-08-07) | Production hard-stops key on the exact `Production` environment name |
|
||||
| SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Done (repo-side; live rotation operator-pending per runbook) | Committed dev LDAP service-account password: remove from repo and rotate |
|
||||
| SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Done | Committed dev LDAP service-account password: remove from repo and rotate |
|
||||
|
||||
---
|
||||
|
||||
@@ -220,3 +220,7 @@ dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --fil
|
||||
(asserts the blank-password validation still fires with the updated message). Manual: with user-secrets set on the dev box, `dotnet run --project src/ZB.MOM.WW.MxGateway.Server/...` and a dashboard `/login` as `multi-role` succeeds against the rotated GLAuth; the deployed-host login re-check from step 1 counts as the production verification. Live-LDAP integration tests (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) only where the GLAuth instance is reachable; otherwise document skipped per the testing matrix.
|
||||
|
||||
**Outcome (2026-08-07 — Done, repo-side; live rotation operator-pending).** Landed on `fix/sec-36-ldap-secret`. **The design's baseline had already shifted:** at HEAD `appsettings.json` no longer commits the literal — it ships `"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}"`, a fail-closed encrypted-store reference (documented `GatewayConfiguration.md:252`, tested by `PreHostSecretExpansionTests`) introduced by the Secrets-store adoption after this remediation was written. **Deviation from Implementation step 2:** the `${secret:}` reference was **kept, not deleted** — deleting it regresses the shipped/documented/tested store channel and the committed-plaintext finding is already resolved for `appsettings.json`. The load-bearing residual — the literal value still present in `glauth.md`'s samples (`:33,65,103,136,245`), `docs/GatewayTesting.md`, and the historical `archreview/*` SEC-06 evidence — was scrubbed to `<service-account-password>` placeholders, each with a pointer to the source of truth `scadaproj/infra/glauth/` and a rotation-required note. Steps 3–6 implemented as designed: `<UserSecretsId>mxaccessgw-server</UserSecretsId>` added (step 3); the `ValidateLdap` blank-password message now names both channels — dev `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` and deployed `MxGateway__Ldap__ServiceAccountPassword` — plus a note on the `${secret:}` store default (step 4), asserted by the extended `Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank`; docs updated same commit (step 5); `git grep -i` for the old value is empty across tracked files (step 6). The cross-repo **step 1 (rotate GLAuth on `10.100.0.35`, pre-stage the NSSM env var on `10.100.0.48` and on `wonder-app-vd03` only if `Ldap.Enabled`, verify dashboard login)** is the operator's to execute, captured in the new runbook `docs/runbooks/SEC-36-ldap-credential-rotation.md`. Verification (macOS): `dotnet build …Server` 0 warnings/0 errors; `dotnet test --filter ~GatewayOptionsValidator` green.
|
||||
|
||||
**Outcome (2026-08-07 — operator half executed; finding now fully `Done`).** The cross-repo step 1 left open above was executed per `docs/runbooks/SEC-36-ldap-credential-rotation.md`. A new service-account password was generated, the `serviceaccount` `passsha256` in `scadaproj/infra/glauth/config.toml` replaced, and the shared GLAuth recreated on `10.100.0.35` from its actual compose directory — the **load-bearing** half of the finding is now discharged: the value disclosed by this repo's git history (live in the directory since 2026-06-04; not reproduced here) no longer binds `dc=zb,dc=local`. The new value exists only in the three channels the design named — the GLAuth `passsha256` (committed in `scadaproj`, commit `aada53b`), the NSSM service environment on `10.100.0.48`, and each dev box's user-secrets — and in no file of this repo. The retired plaintext was additionally scrubbed from the `scadaproj` glauth comments (`config.toml`, `docker-compose.yml`, `README.md`) and from the docker host's live `docker-compose.yml`; the host's `*.bak-sec36` rollback copies deliberately retain it. **Three runbook facts were wrong and are corrected in a dated block at its top.** (1) Its step 3 said `cd ~/Desktop/scadaproj/infra/glauth` on the docker host; no such path exists there — the stack runs from `/home/dohertj2/zb-glauth` (container `zb-shared-glauth`), fed by the `scp` deploy documented in `scadaproj/infra/glauth/README.md`. (2) `wonder-app-vd03` is **out of scope on documentary evidence**, not merely unchecked: its gateway binds the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`, a different directory that never held this credential (the host is also unreachable from the dev network); no env var was staged there. (3) Its "3-fail / 10-minute per-IP lockout" caution is **inert for this instance** — `config.toml:14` sets `LimitFailedBinds = false`. **One Done criterion is met with a caveat:** the new value **is** staged on windev (`10.100.0.48`, 10th `AppEnvironmentExtra` entry on the `MxAccessGw` NSSM service), but the runbook's primary check — dashboard `/login` as `multi-role` — **could not run**, because windev's gateway is crash-looping on an unrelated pre-existing fault: the deployed Server binary (2026-06-25) predates the 2026-07-15 auth-DB migration, so it opens a schema-version-3 database it supports only at version 2 and aborts at startup (~10k Hosting-failed events/day since at least 08-06). That is a stale-deployment problem, filed as a next-cycle candidate finding, not a rotation defect. **Verified instead by the equivalent primitive:** a direct `ldapsearch` bind as `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and returned the `multi-role` entry — the same search bind the dashboard performs. Also surfaced and filed for next cycle: `DashboardLdapLiveTests` fixture drift leaves the suite with **no positive-proof coverage** of the service-account bind, so it could not have substituted for the dashboard check either. Tracking: both registers' SEC-36 rows, the pending-operator-actions list in `90-candidate-findings-next-cycle.md`, and the `00-tracking.md` progress log.
|
||||
|
||||
**Addendum (2026-08-07, later the same day — the caveat is closed).** windev was repaired under NEXT-07 (fresh publish of `origin/main` `a346d51`), and the deferred dashboard check then ran on that host: with `Dashboard:DisableLogin=false` supplied as a process-env-only override on a foreground run, `GET /login` returned 200 with an antiforgery token, `POST /auth/login` as `multi-role`/`password` returned 302 to `/` with a `MxGatewayDashboard` cookie, the authenticated `GET /` rendered the admin nav, and an anonymous control redirected to `/login?ReturnUrl=%2F`. The rotated credential is therefore proven through the real `DashboardAuthenticator` search-bind path on the deployed host, not only by the `ldapsearch` primitive. As deployed windev keeps `DisableLogin=true`, so routine operation there does not exercise LDAP; the standing regression proof is the realigned `DashboardLdapLiveTests` (NEXT-06, commit `de67b45`), 5/5 green against the shared GLAuth. `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3 carries the same record.
|
||||
|
||||
@@ -15,7 +15,7 @@ Prior-cycle open findings (TST-05..24 where still open) are tracked in the prior
|
||||
| TST-27 | Medium | P1 (doc batch) | S | — | Done | `ShowTagValues` config row still says "Reserved" after SEC-25 made the flag live |
|
||||
| TST-28 | Low | P2 | S | relates IPC-02 | Done | Gateway-side `max_frame_bytes` handshake field untested in the CI-run suite |
|
||||
| TST-29 | Low | P2 | S | — | Done | Retire `oldtasks.md` after folding the Phase-5 governance record into DesignDecisions.md; delete root docs-review artifacts |
|
||||
| TST-30 | Low | P2 | M | — | Done (doc half; runner registration operator-pending per runbook) | Single shared Gitea runner is a CI throughput/availability bottleneck (cross-repo contention, no run cancel/delete) |
|
||||
| TST-30 | Low | P2 | M | — | Done | Single shared Gitea runner is a CI throughput/availability bottleneck (cross-repo contention, no run cancel/delete) |
|
||||
|
||||
---
|
||||
|
||||
@@ -169,6 +169,8 @@ Independent of the runner count, document the **no-cancel** reality (Gitea 1.26
|
||||
|
||||
**Outcome (2026-08-07 — Done, doc half; runner registration operator-pending).** Landed on `fix/tst-30-runner-docs`. Implementation step 2 shipped: `docs/GatewayTesting.md`'s Continuous Integration section gained a "Runner capacity is shared and finite" subsection stating the `maxParallel=1` co-located runner is shared with `dohertj2/lmxopcua` at the instance level (not repo-scoped), the ~20–30 minute queue latency observed under cross-repo contention, and the Gitea 1.26 no-cancel/no-delete API reality; the existing "windev tier down" degraded-mode paragraph now also covers "runner contended" as a reason to use the bypass, generalized per this finding's design note. New operator runbook `docs/runbooks/TST-30-second-ci-runner.md` carries **step 1** (register a second `act_runner` on `10.100.0.35`, option (a) recommended, same `container.network: traefik` config; option (b) dedicated labelled runner as an escalation; option (c) windev-hosted runner rejected) with the verification checklist (concurrent back-to-back pushes, `GET /repos/dohertj2/mxaccessgw/actions/runners` ≥ 2) and a note that the no-cancel reality persists regardless of runner count. **Step 3 (optional workflow-level `concurrency` group)** is documented in the runbook as unverified — explicitly framed as "verify this Gitea deployment honors it before relying on it" — and left unimplemented in `ci.yml`, since it is a `ci.yml` change out of scope for this doc-only pass. **The actual runner registration (step 1) is infrastructure work outside this repo's tree and remains the operator's to execute**, tracked in the runbook. Verification performed: `grep -n 'maxParallel\|shared\|cancel' docs/GatewayTesting.md` shows the new prose; runbook file exists at the path above; no build required (doc-only change).
|
||||
|
||||
**Outcome (2026-08-07 — operator half executed; finding now fully `Done`).** The runner registration left open above was executed per `docs/runbooks/TST-30-second-ci-runner.md` option (a). A second instance-level `act_runner` container, `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/`ubuntu-22.04`), now runs on `10.100.0.35` from the `/opt/gitea` compose stack with the same `container.network: traefik` setting as the original; its registration token is mounted from a `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was **not** modified — capacity went 4 → 6 by addition, so the change is reversible by removing one container. Concurrency verified live by pushing HEAD (`a346d51`) to two scratch branches, `scratch/tst30-a` (run 661) and `scratch/tst30-b` (run 662), while an unrelated run (660) was already in flight: at 13:07:53Z jobs from **three** runs were `in_progress` simultaneously — run 660 `portable` and run 662 `portable`/`java` on runner 1, run 661 `portable`/`java` on `gitea-runner-2` — which the pre-change single-runner topology could not have produced. `gitea:3000` resolution holds on the new instance: run 661's `portable` job (task 1145, scheduled on `gitea-runner-2`) logged `git remote add origin http://gitea:3000/dohertj2/mxaccessgw` followed by a successful `fetch … From http://gitea:3000/dohertj2/mxaccessgw`, and its job container's workspace was confirmed checked out at `a346d514dd24e775640e5667aa7cd8e561fec68a`. **Runbook correction:** its verification checklist said `GET /repos/dohertj2/mxaccessgw/actions/runners` should show ≥2 — that endpoint still returns `total_count: 0` because both runners are registered at the **instance** level, exactly as this finding documented; the correct check is `GET /api/v1/admin/actions/runners`, which lists ids 1, 4 (an unrelated local macOS runner), and 5. Recorded as a dated "Executed" note at the top of the runbook. The no-cancel reality is unchanged and the `run-windev-ci.sh` bypass remains valid, so `docs/GatewayTesting.md`'s prose needed no edit.
|
||||
|
||||
---
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Candidate Findings for the Next Review Cycle (surfaced during 2026-07-12 remediation)
|
||||
|
||||
These were discovered while remediating the 2026-07-12 backlog but were **out of scope** for it — each is either pre-existing, by-design residual, or a new observation. They are recorded here (not fixed) so the next review cycle can triage them. None blocks the 2026-07-12 cycle, which is complete.
|
||||
These were discovered while remediating the 2026-07-12 backlog but were **out of scope** for it — each is either pre-existing, by-design residual, or a new observation. They are recorded here so the next review cycle can triage them. None blocks the 2026-07-12 cycle, which is complete. Rows struck through have since been fixed ahead of that cycle; the original finding text is kept so the triage record stays readable.
|
||||
|
||||
| ID (proposed) | Area | Severity (est.) | Summary |
|
||||
|---|---|---|---|
|
||||
@@ -9,11 +9,17 @@ These were discovered while remediating the 2026-07-12 backlog but were **out of
|
||||
| NEXT-03 | Gateway alarms | Low | `GatewayAlarmMonitor.ApplyReconcile` feed-repair broadcasts (the new acked-delta from GWC-26 **and** the pre-existing Raise/Clear repair) are **at-least-once, not exactly-once**: a periodic reconcile can synthesize a transition whose matching live transition is still buffered in the alarm lease, so both broadcast as indistinguishable duplicates on the alarm feed (StreamAlarms + dashboard hub). Pre-existing (the Raise/Clear repair always had it); GWC-26 documented the at-least-once contract rather than closing the race. Closing it needs reconcile/live serialization or a monotonic dedup marker. |
|
||||
| NEXT-04 | Worker frame writer | Low | WRK-22/WRK-25 cancellation path: a frame `Claimed` by a concurrent lock-holder just before its caller's cancellation races in is never awaited by that caller; if the write then faults, `TrySetException` lands on a `Task` nobody observes (unobserved-task-exception). By-design residual, non-crash (no `UnobservedTaskException` handler registered), pre-existing to single-frame WRK-22 and amplified per-batch by WRK-25. Hygiene fix: attach a fault-observing continuation to abandoned/tombstoned frame completions. |
|
||||
| NEXT-05 | Worker frame writer | Info | A batch whose remaining frames are tombstoned by cancellation leaves dead `PendingFrame` entries in `_eventFrames`/`_controlFrames` until a future `DequeueNext` pops and skips them. Same pre-existing behavior as single-frame WRK-22, amplified per-batch; in practice heartbeats purge them promptly, so not a real leak. |
|
||||
| ~~NEXT-06~~ | Testing / live LDAP | Medium | **Resolved 2026-08-07** — fixtures realigned to the shared directory (`admin`/`password` for the GwAdmin success path, `gw-viewer`/`password` for the bind-succeeds-but-no-role path); verified `Failed: 0, Passed: 5` live against the shared GLAuth at `10.100.0.35:3893`, so the success-path assertion (GwAdmin group claim + Admin role claim) now fails if the service-account credential is wrong. Original finding: `DashboardLdapLiveTests` fixtures have drifted from the shared GLAuth directory, leaving the suite with **no positive-proof coverage of the service-account bind**. Its only success-path test, `AuthenticateAsync_AdminInGwAdminGroup_Succeeds`, binds `admin`/`admin123`, but the directory's `admin` user carries the standard dev password (`scadaproj/infra/glauth/config.toml`), so that assertion cannot pass. `AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails` binds fixture user `readonly`, which **does not exist** in the GLAuth config at all — it passes for the wrong reason (user-not-found rather than the group-missing branch it names; the `readonly` name is in fact barred by the README's user/group case-collision rule). The three remaining tests are negative assertions that pass whether or not the service account can bind. Net effect: a green `DashboardLdapLiveTests` run proves nothing about the bind credential — surfaced during SEC-36, where the suite was considered as a substitute for the deferred dashboard-login check and rejected. Fix: realign the fixtures to real directory users (e.g. `multi-role`/`gw-viewer`) or add the missing users to the GLAuth config, and add one test that fails when the service-account credential is wrong. |
|
||||
| ~~NEXT-07~~ | Deployment / windev | High | **Resolved 2026-08-07** — a fresh portable framework-dependent publish of `origin/main` (`a346d51`) was built in a clean clone at `C:\build\mxgw-redeploy`, deployed to `C:\publish\mxaccessgw\Server-20260807`, and the `MxAccessGw` NSSM service repointed at it; the service now holds a stable PID with both `5120`/`5130` listening, a worker spawned, the Galaxy snapshot restored (129 objects / 56,731 attributes) and a clean event log. Root cause confirmed as the version skew this row predicted: the deployed 2026-06-25 build carried `ZB.MOM.WW.Auth.ApiKeys` 0.1.2.0, which supports auth-DB schema 2, against a `gateway-auth.db` stamped at schema 3 on 2026-07-15 by an ephemeral run of newer code — schema 3 is the current shared-lib version (`SqliteAuthSchema.CurrentVersion=3` in Auth 0.1.5), so the redeploy is the forward fix and the DB was left alone. Rollback artifacts kept: `C:\ProgramData\MxGateway\gateway-auth.db.bak-next07` (with `-wal`/`-shm`) and the previous `C:\publish\mxaccessgw\Server` directory. Two side effects worth recording: the old deploy's `appsettings.json` held the LDAP bind password in **plaintext on disk**, while the new one keeps the repo's `${secret:ldap/mxgateway/bind}` token with the NSSM environment supplying the value, so no plaintext LDAP secret remains on that host; and the redeploy tripped the SEC-06 `Ldap:Transport=None` production hard-stop (`GatewayOptionsValidator.cs:178`), resolved by relabelling the host — windev runs `Dashboard:DisableLogin=true`, which this repo's own docs mark dev/test-only, so its `Production` label contradicted its configuration and `DOTNET_ENVIRONMENT` was changed to `Staging` (that one NSSM environment entry only; the other nine preserved byte-identical). SEC-06 is untouched for genuinely production hosts — see NEXT-08 for the posture problem that relabelling defers. Original finding: The `10.100.0.48` (windev) gateway deployment is **stale and crash-looping**, and has been since at least 2026-08-06 (~10k Hosting-failed events/day). The deployed Server binary dates to 2026-06-25 and predates the auth-DB migration of 2026-07-15: it opens a schema-version-3 `gateway-auth.db` that it supports only at version 2 and aborts at startup, so the `MxAccessGw` service never reaches a listening state. Not a code defect in the current tree — a deploy-drift/operations gap — but it means the repo's only deployed host has been dark for over a day and any host-level verification (including SEC-36's dashboard-login check) is blocked until it is repaired. Fix: deploy a current Server build to windev, or restore/downgrade the auth DB to schema 2 if the old binary must stand. Worth asking separately why a service in a permanent restart loop raised no alert. Discovered during SEC-36. |
|
||||
| NEXT-08 | Security / LDAP posture | Medium | **The shared GLAuth offers no TLS, so SEC-06 makes it undeployable from a `Production`-labelled host.** `GatewayOptionsValidator` (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:178`) refuses to start when `Ldap:Transport=None` in the `Production` environment, and `docs/GatewayConfiguration.md`'s `Transport` row states "Deployed hosts must set `Ldaps` or `StartTls`" — but the shared instance at `10.100.0.35:3893` has `[ldaps] enabled=false`, port `3894` closed, and answers StartTLS with `protocolError`, so neither value can work against it. That instruction is currently unsatisfiable for every host that authenticates there. windev sidestepped it on 2026-08-07 by moving to the `Staging` environment name (NEXT-07), which is honest for a dev/test rig but is not available to a real production host. Resolution needs either LDAPS/StartTLS on the shared GLAuth (certificate plus a trust story on each gateway host) or an explicit written posture decision that production gateways bind a different, TLS-capable directory. Surfaced during the NEXT-07 redeploy. |
|
||||
| NEXT-09 | Build / versioning | Low | **Windows builds stamp git's error text into `InformationalVersion`.** `src/Directory.Build.props:29` runs `git -C "$(MSBuildThisFileDirectory)" …`; MSBuild's directory property ends in a backslash, which escapes the closing quote, so the command is malformed on Windows. The target carries `ContinueOnError`, so the failure is silent and git's stderr is captured as the revision — an observed stamp reads `0.1.2+fatal: cannot change to …`. Any Windows build without a preset `SourceRevisionId` therefore ships a binary that cannot be correlated back to a commit, defeating the point of TST-11. Not reproducible on macOS/Linux, where the separator is `/`. Fix sketch: append `.` to the path or trim the trailing separator before quoting. Surfaced while identifying the deployed binary during NEXT-07. |
|
||||
| NEXT-10 | Docs / glauth | Medium | **`glauth.md`'s "Pre-provisioned users" table contradicts both the directory and the rest of its own file.** It documents `readonly`/`readonly123` and `admin`/`admin123`, neither of which matches `scadaproj/infra/glauth/config.toml` (`readonly` does not exist there; `admin` carries the standard dev password), and lists the `ReadOnly` gid as `5501` against an actual `5601`. Its dashboard section, by contrast, is correct — so the file is internally inconsistent and a reader cannot tell which half to trust. This table was the **root cause of the NEXT-06 fixture drift**, and it has propagated further: `docs/GatewayTesting.md`'s `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default and the matching literal in `WorkerLiveMxAccessSmokeTests` both take `admin123` from it. Deliberately **not** fixed in the 2026-08-07 pass: the table is entangled with the OPC-UA group taxonomy (gids, role mapping, and the sister-repo consumers of the same directory), so reconciling it means sweeping that taxonomy as one unit rather than patching two rows. |
|
||||
|
||||
## Operator actions still pending (from this cycle's runbooks)
|
||||
|
||||
These are **live-infrastructure actions the operator must execute** — the repo-side work is complete and merged:
|
||||
|
||||
- **SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**
|
||||
- **TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck.
|
||||
- ~~**SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**~~ **Executed 2026-08-07**: the `serviceaccount` `passsha256` was replaced in `scadaproj/infra/glauth/config.toml` (commit `aada53b`) and the shared GLAuth recreated on `10.100.0.35`, so the literal recoverable from this repo's history no longer binds `dc=zb,dc=local`. The new value lives only in the GLAuth hash, windev's NSSM environment, and dev user-secrets. `wonder-app-vd03` was out of scope (it binds a different, `dc=scadalink`/`dc=scadabridge` directory). **Caveat, since closed:** windev's dashboard `/login` verification was deferred while that host's gateway was crash-looping on the unrelated stale-deployment fault filed as NEXT-07, so the bind was verified directly by `ldapsearch` as `cn=serviceaccount,dc=zb,dc=local` instead. After the 2026-08-07 redeploy the real check ran on windev (login as `multi-role` → 302 + dashboard cookie, anonymous control → `/login`), so the rotated credential is now proven through the `DashboardAuthenticator` path itself; see `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3. SEC-36 is fully `Done`.
|
||||
- ~~**TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck.~~ **Executed 2026-08-07**: `gitea-runner-2` (id 5, capacity 2) is online on `10.100.0.35` via the `/opt/gitea` compose stack, same `container.network: traefik`, token from a `0600` file mount; the existing runner (id 1, capacity 4) was untouched. Concurrency verified — jobs from three runs ran simultaneously across both runners, and a `gitea-runner-2` job cloned successfully from `http://gitea:3000`. TST-30 is now fully `Done`.
|
||||
- **TST-30 follow-up — reset the Gitea instance runner registration token.** Runner-1's compose block was moved to the same `0600` file-mount pattern as runner-2 on 2026-08-07 (compose and both backups now `0600 root:root`, runner-1 recreated with its identity intact), but hygiene alone does not retire the token: both runners share **one instance-scope registration token** that was world-readable for roughly five months and is still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 exposes no rotation via CLI or API (both paths are get-or-create and hand back the same value), so the reset must be done in the admin web UI ("Reset registration token"). Afterwards, refresh `/opt/gitea/runner_token` on `10.100.0.35` and shred the two token-bearing compose backups — they are the last copies of the old value. See `docs/runbooks/TST-30-second-ci-runner.md`.
|
||||
- **TST-25 follow-ups** — old **TST-05** (scheduled live-MXAccess smoke) is now covered by the `nightly-windev` job; old **TST-24** (client wire tests in CI) is unblocked by the working Windows tier.
|
||||
|
||||
@@ -99,6 +99,18 @@ numbers are still unclaimed — the guards above do this automatically at
|
||||
publish time, but a version bump in the source is still a manual step per
|
||||
client.
|
||||
|
||||
On 2026-08-07 that release shipped. Published coordinates on
|
||||
`gitea.dohertylan.com`: `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.0** and
|
||||
`ZB.MOM.WW.MxGateway.Contracts` **0.2.0**, `pypi`
|
||||
`zb-mom-ww-mxaccess-gateway-client` **0.2.0**, `cargo`
|
||||
`zb-mom-ww-mxgateway-client` **0.2.0**, `maven`
|
||||
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.1** (the Java
|
||||
exception described above). Go publishes no artifact — it ships as the module
|
||||
tag `clients/go/v0.2.0`, created at commit `a346d51`. Each coordinate was
|
||||
confirmed present through the Gitea package API after the push, and
|
||||
`go list -m` resolves the Go tag. These are the numbers a future release
|
||||
bumps off.
|
||||
|
||||
## .NET
|
||||
|
||||
The .NET client uses .NET 10 and references
|
||||
@@ -200,6 +212,12 @@ a `cargo package` that cannot build from the vendored tree alone would mean
|
||||
the vendored copies are stale, and verification is what catches that before
|
||||
publish.
|
||||
|
||||
Publishing to the `dohertj2-gitea` alternative registry reads the token from
|
||||
`CARGO_REGISTRIES_DOHERTJ2_GITEA_TOKEN`, and that variable must hold
|
||||
`Bearer <token>` — cargo sends the value as the `Authorization` header
|
||||
verbatim and Gitea's cargo registry rejects a bare token with `401`, unlike
|
||||
the other feeds, which authenticate with a username/token basic-auth pair.
|
||||
|
||||
Regenerate and compile Rust bindings:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -249,7 +249,7 @@ dev/test GLAuth posture (`glauth.md`), not a production posture.
|
||||
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
|
||||
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
|
||||
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
|
||||
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **Never a committed plaintext value (SEC-36):** the shared GLAuth bind credential is supplied out-of-band through one of three channels, all binding to this key. **(1) Encrypted secrets store (shipped default):** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed it once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. **(2) Deployed hosts — env var:** override directly with `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) in the NSSM service environment — a plain literal there is used as-is and the store lookup is skipped. **(3) Dev boxes — user-secrets:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` (the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`; user-secrets load automatically in the Development environment and live under the user profile, outside the tree). The value comes from the GLAuth source of truth `scadaproj/infra/glauth/`, never from a repo file. **Rotation:** because the credential was historically committed, rotating it in `scadaproj/infra/glauth/` (and redeploying the shared GLAuth on `10.100.0.35`) is required — see `docs/runbooks/SEC-36-ldap-credential-rotation.md` for the cutover order. A blank/unresolved value fails startup validation with a message naming the two supported channels. |
|
||||
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **Never a committed plaintext value (SEC-36):** the shared GLAuth bind credential is supplied out-of-band through one of three channels, all binding to this key. **(1) Encrypted secrets store (shipped default):** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed it once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. **(2) Deployed hosts — env var:** override directly with `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) in the NSSM service environment — a plain literal there is used as-is and the store lookup is skipped. **(3) Dev boxes — user-secrets:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` (the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`; user-secrets load automatically in the Development environment and live under the user profile, outside the tree). The value comes from the GLAuth source of truth `scadaproj/infra/glauth/`, never from a repo file. **Rotation:** because the credential was historically committed, it was rotated in `scadaproj/infra/glauth/` (and the shared GLAuth on `10.100.0.35` redeployed) on 2026-08-07 (SEC-36) — see `docs/runbooks/SEC-36-ldap-credential-rotation.md` for the cutover procedure used, and for future rotations. A blank/unresolved value fails startup validation with a message naming the two supported channels. |
|
||||
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
|
||||
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
|
||||
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
|
||||
@@ -273,6 +273,14 @@ staging rig, e.g. one pointed at the plaintext shared GLAuth). A production-like
|
||||
deployment must therefore run with the literal `Production` environment name for
|
||||
the hard-stops to apply.
|
||||
|
||||
`windev` (`10.100.0.48`) is deliberately labelled `Staging` (its NSSM
|
||||
`DOTNET_ENVIRONMENT` entry, set 2026-08-07) rather than left at the `Production`
|
||||
default. It is the permissive rig the parenthesis above describes: it runs
|
||||
`Dashboard:DisableLogin=true` and binds the shared GLAuth, which offers no TLS,
|
||||
so a `Production` label would contradict its own configuration and both
|
||||
hard-stops would refuse the boot. Label a host `Production` only when its
|
||||
configuration can satisfy them.
|
||||
|
||||
## Secrets Master Key
|
||||
|
||||
`${secret:...}` tokens in configuration — currently just
|
||||
|
||||
+40
-24
@@ -215,13 +215,21 @@ service described in `glauth.md`.
|
||||
|
||||
The suite builds the authenticator with `GatewayOptions.Dashboard.GroupToRole`
|
||||
set to `{ GwAdmin: Admin }`. `GwAdmin` is the gateway-specific
|
||||
dashboard-admin role and is **not** part of the five baseline GLAuth role
|
||||
dashboard-admin role and is **not** part of the baseline GLAuth role
|
||||
groups — it must be provisioned before the LDAP live tests pass.
|
||||
`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips)
|
||||
when GLAuth has only the baseline groups, so this is a hard prerequisite
|
||||
beyond "LDAP is up." See the "Adding a gw-specific group" section of
|
||||
`glauth.md` for the provisioning step that adds `GwAdmin` and grants it to
|
||||
`admin`.
|
||||
beyond "LDAP is up." The shared directory
|
||||
(`scadaproj/infra/glauth/config.toml`) already provisions `GwAdmin` (gid 5610)
|
||||
and `GwReader` (gid 5611); see the "Adding a gw-specific group" section of
|
||||
`glauth.md` for the per-box equivalent.
|
||||
|
||||
The fixtures name real users from that shared config, so a run only proves the
|
||||
service-account bind when it targets the shared directory. `appsettings.json`
|
||||
ships `Server=localhost` for the local-forward case, so point the suite at the
|
||||
shared GLAuth with `MxGateway__Ldap__Server=10.100.0.35`; the suite's
|
||||
`AddEnvironmentVariables()` layer applies the override to the same
|
||||
`MxGateway:Ldap` section production binds.
|
||||
|
||||
`DashboardAuthenticator` delegates the LDAP bind and group search to the shared
|
||||
`ZB.MOM.WW.Auth.Ldap` provider (`LdapAuthService`) and only maps the resulting
|
||||
@@ -229,12 +237,17 @@ groups to dashboard roles via `DashboardGroupRoleMapper`; the bind/search
|
||||
mechanics that decide each outcome live in that shared provider, not in
|
||||
`DashboardAuthenticator`.
|
||||
|
||||
The suite covers both the success path and the failure outcomes: `admin` whose
|
||||
LDAP groups resolve to the `Admin` role succeeds and emits the role claim;
|
||||
`readonly` is denied because no group in their `memberOf` appears in
|
||||
`GroupToRole`; `admin` with a wrong password fails authentication without leaking
|
||||
the password into `FailureMessage`; an unknown username fails authentication; and
|
||||
an unreachable LDAP server is absorbed into a failed result rather than throwing.
|
||||
The suite covers both the success path and the failure outcomes: `admin`, whose
|
||||
`othergroups` include `GwAdmin`, succeeds and emits the role claim — this is the
|
||||
one test that proves the service-account bind, because every other outcome below
|
||||
fails identically whether or not the bind credential is right; `gw-viewer` is
|
||||
denied because its only group (`GwReader`) is absent from `GroupToRole`, and its
|
||||
denial message must match the unknown-user denial so an authorization failure
|
||||
cannot be used to enumerate valid accounts; `admin` with a wrong password fails
|
||||
authentication without leaking the password into `FailureMessage`; an unknown
|
||||
username fails authentication; and an unreachable LDAP server is absorbed into a
|
||||
failed result rather than throwing. Both live users bind with the shared dev
|
||||
password documented in `glauth.md`.
|
||||
|
||||
`appsettings.json` now ships the LDAP bind password as the unexpanded
|
||||
`${secret:ldap/mxgateway/bind}` token (resolved at gateway startup by the
|
||||
@@ -249,6 +262,7 @@ Run the LDAP live tests explicitly:
|
||||
|
||||
```bash
|
||||
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
|
||||
$env:MxGateway__Ldap__Server = "10.100.0.35"
|
||||
$env:MxGateway__Ldap__ServiceAccountPassword = "<service-account-password>"
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
|
||||
```
|
||||
@@ -455,20 +469,22 @@ runtime because the x86 Worker cannot build on Linux:
|
||||
|
||||
### Runner capacity is shared and finite
|
||||
|
||||
All CI runs on a co-located `gitea-runner` container on docker host `10.100.0.35` with
|
||||
`maxParallel=1`, and that runner instance is **shared across repos** — it interleaves
|
||||
`dohertj2/mxaccessgw` and `dohertj2/lmxopcua` jobs on the single slot rather than being
|
||||
scoped to this repo (`GET /repos/dohertj2/mxaccessgw/actions/runners` returns
|
||||
`total_count: 0`; the runner is registered at the instance level). Every job in a run
|
||||
(`portable`, `java`, `windows-x86`) executes serially, so queue latency is additive within
|
||||
a run and an active `lmxopcua` run blocks `mxaccessgw` entirely — expect ~20–30 minutes of
|
||||
queue depth under cross-repo contention, not a stuck pipeline. This Gitea version (1.26)
|
||||
also exposes **no run cancel or delete via the API** (`POST .../actions/runs/{id}/cancel`
|
||||
returns 404, `DELETE .../actions/runs/{id}` returns 400), so a superseded or hung run cannot
|
||||
be cleared and holds the slot until it finishes or times out. See
|
||||
`docs/runbooks/TST-30-second-ci-runner.md` for the operator runbook that registers a second
|
||||
runner to relieve this; until that lands, treat single-runner contention as expected, not a
|
||||
CI outage.
|
||||
CI runs on two co-located runner containers on docker host `10.100.0.35` — `gitea-runner`
|
||||
(capacity 4) and `gitea-runner-2` (capacity 2, registered 2026-08-07 per
|
||||
`docs/runbooks/TST-30-second-ci-runner.md`) — and both runner instances are **shared across
|
||||
repos**: they interleave `dohertj2/mxaccessgw` and `dohertj2/lmxopcua` jobs across the
|
||||
combined slots rather than being scoped to this repo (`GET
|
||||
/repos/dohertj2/mxaccessgw/actions/runners` returns `total_count: 0`; both runners are
|
||||
registered at the instance level). Every job in a run (`portable`, `java`, `windows-x86`)
|
||||
still executes serially within that run, so queue latency is additive within a run, but an
|
||||
active `lmxopcua` run no longer blocks `mxaccessgw` entirely the way a single shared slot
|
||||
did — the two runners relieve cross-repo contention. This Gitea version (1.26) also exposes
|
||||
**no run cancel or delete via the API** (`POST .../actions/runs/{id}/cancel` returns 404,
|
||||
`DELETE .../actions/runs/{id}` returns 400), so a superseded or hung run cannot be cleared
|
||||
and holds the slot until it finishes or times out — with two runners this means a single
|
||||
wedged run can still hold slots, because the no-cancel reality is unchanged. See
|
||||
`docs/runbooks/TST-30-second-ci-runner.md` for the operator runbook that registered the
|
||||
second runner.
|
||||
|
||||
When queue depth (or the missing-cancel reality) makes waiting impractical, verify a
|
||||
specific commit out of band instead of waiting behind the queue: run
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# SEC-36 — LDAP Service-Account Credential Rotation (Operator Runbook)
|
||||
|
||||
> **Executed 2026-08-07 — the rotation is done; this runbook is now history plus the four
|
||||
> corrections below.** A new service-account password was generated, `scadaproj/infra/glauth/config.toml`'s
|
||||
> `serviceaccount` `passsha256` was replaced and the shared GLAuth recreated, and the old value
|
||||
> (the literal this repo committed, live in the directory since 2026-06-04) no longer binds. The new
|
||||
> value now exists only in the
|
||||
> three channels this runbook names: the GLAuth `passsha256` (committed in `scadaproj`), the NSSM
|
||||
> service environment on `10.100.0.48`, and this dev Mac's user-secrets. The retired plaintext was
|
||||
> also scrubbed from `scadaproj/infra/glauth/`'s `config.toml`/`docker-compose.yml`/`README.md`
|
||||
> comments and from the host's live `docker-compose.yml` (the `*.bak-sec36` backups on the host still
|
||||
> carry it, deliberately — they are the rollback artifacts).
|
||||
>
|
||||
> **Correction 1 — host paths in step 3 were stale.** The runbook says
|
||||
> `cd ~/Desktop/scadaproj/infra/glauth` on `10.100.0.35`. That directory does not exist there:
|
||||
> `scadaproj` is a dev-workstation checkout, and the docker host runs the stack from
|
||||
> **`/home/dohertj2/zb-glauth`** (container **`zb-shared-glauth`**, project name `zb-shared-glauth`).
|
||||
> The repo remains the source of truth; deployment is the `scp` of `config.toml`/`docker-compose.yml`
|
||||
> into `~/zb-glauth` documented in `scadaproj/infra/glauth/README.md`, followed by
|
||||
> `docker compose up -d --force-recreate` there.
|
||||
>
|
||||
> **Correction 2 — `wonder-app-vd03` is out of scope, on documentary evidence.** The precondition
|
||||
> above says to check `MxGateway:Ldap:Enabled` on that host. It could not be checked directly (the
|
||||
> host is unreachable from the dev network), but it is out of scope regardless: its gateway binds a
|
||||
> **different directory** — the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`,
|
||||
> not `dc=zb,dc=local` — so this credential is not one it can hold. No env var was staged there and
|
||||
> none is needed.
|
||||
>
|
||||
> **Correction 3 — step 4's dashboard verification is deferred on `10.100.0.48`; a direct bind was
|
||||
> used instead.** The NEW value **is** staged on windev (added as the 10th `AppEnvironmentExtra`
|
||||
> entry on the `MxAccessGw` NSSM service), but dashboard `/login` could not exercise it at rotation
|
||||
> time: windev's gateway was **crash-looping on a pre-existing, unrelated fault** — the deployed Server binary
|
||||
> (2026-06-25) predates the auth-DB migration of 2026-07-15, so it opens a schema-version-3 database
|
||||
> it only supports at version 2 and aborts at startup (~10k Hosting-failed events/day since at least
|
||||
> 08-06). That was a stale-deployment problem, not a rotation problem; it was filed as next-cycle
|
||||
> finding NEXT-07 and resolved by redeploy on 2026-08-07. **Verification used instead:** a direct `ldapsearch` bind as
|
||||
> `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and
|
||||
> returned the `multi-role` entry — which is precisely the search bind the dashboard performs, minus
|
||||
> the HTTP shell. **The deferred check was completed 2026-08-07**, once windev was repaired by the
|
||||
> redeploy filed under NEXT-07. With `Dashboard:DisableLogin=false` supplied as a process-env-only
|
||||
> override on a foreground run of the new build, `GET /login` returned 200 with an antiforgery token,
|
||||
> `POST /auth/login` as `multi-role`/`password` returned 302 to `/` with a `MxGatewayDashboard`
|
||||
> cookie, the authenticated `GET /` rendered the admin nav, and an anonymous control redirected to
|
||||
> `/login?ReturnUrl=%2F` — so the rotated credential is proven through the real
|
||||
> `DashboardAuthenticator` search-bind path on the deployed host. As deployed, windev keeps
|
||||
> `DisableLogin=true`, so routine operation there does not exercise LDAP; the standing regression
|
||||
> proof is `DashboardLdapLiveTests` (5/5 green against `10.100.0.35` since commit `de67b45`).
|
||||
>
|
||||
> **Correction 4 — the lockout caution under "Verifying the rotation" is inert for this instance.**
|
||||
> It warns that GLAuth's 3-fail / 10-minute per-IP lockout can lock the whole office when testing
|
||||
> that the old value is dead. This GLAuth runs `LimitFailedBinds = false` (`config.toml:14`), so no
|
||||
> failed-bind limiter is active and the caution does not apply here. Keep the caution for any
|
||||
> instance that enables the limiter.
|
||||
|
||||
Operator steps to rotate the shared GLAuth service-account password after the repo-side
|
||||
removal landed (SEC-36). The repo change (removal of the committed value, the two supported
|
||||
secret channels, and this runbook) is already merged; the live rotation below is the
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# TST-30 — Register A Second CI Runner (Operator Runbook)
|
||||
|
||||
> **Executed 2026-08-07 — option (a) shipped; this runbook is now history plus the one
|
||||
> correction below.** `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/
|
||||
> `ubuntu-22.04`) runs on `10.100.0.35` from the `/opt/gitea` compose stack with the same
|
||||
> `container.network: traefik` setting as the original; its registration token is mounted from a
|
||||
> `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was
|
||||
> left untouched, so capacity went 4 → 6 by addition and the change reverts by removing one
|
||||
> container. Concurrency was verified by pushing HEAD to two scratch branches while an unrelated
|
||||
> run was in flight: jobs from three runs ran simultaneously across both runners, and a
|
||||
> `gitea-runner-2` job cloned successfully from `http://gitea:3000` (the property option (c) was
|
||||
> rejected for losing).
|
||||
>
|
||||
> **Correction to the Verification and Done-criteria sections below:** they expect
|
||||
> `GET /repos/dohertj2/mxaccessgw/actions/runners` to show ≥2 runners. It does not — it still
|
||||
> returns `total_count: 0`, correctly, because both runners are registered at the **instance**
|
||||
> level, which is the very condition the "Why" section describes. Use
|
||||
> `GET /api/v1/admin/actions/runners` instead (it now lists only id 1 and id 5; id 4 was removed
|
||||
> 2026-08-07 — a local macOS `act_runner` mislabelled `ubuntu-latest`/`ubuntu-22.04`/`ubuntu-20.04`,
|
||||
> so it captured Linux-labelled jobs it had no Docker daemon to run and failed them. Its config and
|
||||
> registration are kept disabled at `~/gitea-act-runner.disabled-2026-08-07` (launchd plist at
|
||||
> `~/Library/LaunchAgents/com.dohertj2.gitea-act-runner.plist.disabled-2026-08-07`) so it can be
|
||||
> re-registered with mac-specific labels if a mac-only job ever needs one). For per-job runner
|
||||
> attribution, `GET /repos/{owner}/{repo}/actions/runs/{id}/jobs`
|
||||
> exposes `runner_id`/`runner_name` on each job; the `actions/tasks` listing does not.
|
||||
>
|
||||
> **Follow-up 2026-08-07 — token hygiene on the host.** `gitea-runner` (id 1) now takes its
|
||||
> registration token from the same `0600` file mount runner-2 uses instead of an inline plaintext
|
||||
> value in compose, and `/opt/gitea/docker-compose.yml` plus both `.bak` copies are `0600 root:root`;
|
||||
> runner-1 was recreated alone and kept its identity (`.runner` byte-identical). Both runners share
|
||||
> **one instance-scope registration token**, which was world-readable for roughly five months and is
|
||||
> still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 cannot rotate
|
||||
> that token from the CLI or the API (both endpoints are get-or-create and return the same value),
|
||||
> so **the reset is a pending operator action in the admin web UI** ("Reset registration token").
|
||||
> After the reset, refresh `/opt/gitea/runner_token` with the new value and shred the two
|
||||
> token-bearing compose backups, which are the last copies of the old one.
|
||||
|
||||
Operator steps to relieve the single shared Gitea Actions runner that CI depends on. The
|
||||
repo-side half of TST-30 (documenting the shared-runner/no-cancel reality and the
|
||||
`run-windev-ci.sh` bypass) is already landed in `docs/GatewayTesting.md`; registering the
|
||||
|
||||
@@ -40,8 +40,9 @@ gw-specific role.
|
||||
> it out-of-band (encrypted secrets store reference `${secret:ldap/mxgateway/bind}`, the
|
||||
> `MxGateway__Ldap__ServiceAccountPassword` env var on deployed hosts, or `dotnet user-secrets`
|
||||
> on dev boxes — see `docs/GatewayConfiguration.md`). The credential was historically committed
|
||||
> to this repo (and remains recoverable from git history), so **rotation is required**; the
|
||||
> operator runbook is `docs/runbooks/SEC-36-ldap-credential-rotation.md`.
|
||||
> to this repo (and remains recoverable from git history); it **was rotated on 2026-08-07**
|
||||
> (SEC-36, executed per `docs/runbooks/SEC-36-ldap-credential-rotation.md`) and the old
|
||||
> committed value no longer binds.
|
||||
|
||||
## Pre-existing groups (LmxOpcUa role taxonomy)
|
||||
|
||||
|
||||
@@ -14,7 +14,19 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class DashboardLdapLiveTests
|
||||
{
|
||||
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
|
||||
/// <summary>
|
||||
/// The shared dev/test directory issues every human tester the same well-known password, so
|
||||
/// the fixtures name it once rather than repeating a literal that drifts per test. This is a
|
||||
/// published dev credential (see <c>glauth.md</c> and <c>scadaproj/infra/glauth/config.toml</c>),
|
||||
/// not a secret — unlike the service-account bind password, which is never in source and must
|
||||
/// arrive via <c>MxGateway__Ldap__ServiceAccountPassword</c>.
|
||||
/// </summary>
|
||||
private const string SharedDirectoryPassword = "password";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <c>admin</c> — a shared-directory user whose <c>othergroups</c> include
|
||||
/// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[LiveLdapFact]
|
||||
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
||||
@@ -23,7 +35,7 @@ public sealed class DashboardLdapLiveTests
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
"admin123",
|
||||
SharedDirectoryPassword,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
@@ -38,21 +50,43 @@ public sealed class DashboardLdapLiveTests
|
||||
&& claim.Value == DashboardRoles.Admin);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
|
||||
/// <summary>
|
||||
/// Verifies that <c>gw-viewer</c> — a shared-directory user whose only group is GwReader
|
||||
/// (gid 5611), which this suite's GroupToRole map deliberately leaves unmapped — is denied
|
||||
/// even though its bind succeeds, and that the denial is indistinguishable from the
|
||||
/// unknown-user denial.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[LiveLdapFact]
|
||||
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
|
||||
public async Task AuthenticateAsync_ViewerMissingGwAdminGroup_FailsIndistinguishably()
|
||||
{
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"readonly",
|
||||
"readonly123",
|
||||
"gw-viewer",
|
||||
SharedDirectoryPassword,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.Principal);
|
||||
Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal);
|
||||
|
||||
// This test used to assert the failure message did not echo the credential literal.
|
||||
// That check cannot survive the move to the shared directory: the real password is the
|
||||
// word "password", which legitimately occurs in the generic denial text ("The username
|
||||
// or password is invalid, ..."), so the assertion would fail for the wrong reason. The
|
||||
// no-leak property is still covered — with a distinctive literal — by
|
||||
// AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword below. What is
|
||||
// asserted here instead is the property this fixture is actually uniquely able to prove:
|
||||
// an authorization failure (valid credentials, no mapped role) must be reported with the
|
||||
// same message as an authentication failure, so the response cannot be used to enumerate
|
||||
// valid accounts.
|
||||
DashboardAuthenticationResult unknownUserResult = await authenticator.AuthenticateAsync(
|
||||
"no-such-user-9f3c1",
|
||||
"irrelevant-password",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.FailureMessage));
|
||||
Assert.Equal(unknownUserResult.FailureMessage, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
||||
@@ -98,9 +132,11 @@ public sealed class DashboardLdapLiveTests
|
||||
[LiveLdapFact]
|
||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||
{
|
||||
// Exercises the connect-failure path: a closed loopback port produces a
|
||||
// connection error that the shared LdapAuthService must absorb into a Fail
|
||||
// result rather than propagating an exception to the dashboard.
|
||||
// Exercises the connect-failure path: overriding only the port keeps whatever host
|
||||
// the run targets (localhost by default, the shared GLAuth under the
|
||||
// MxGateway__Ldap__Server override) while pointing at a port nothing listens on, so
|
||||
// the connection error the shared LdapAuthService must absorb into a Fail result —
|
||||
// rather than propagate as an exception to the dashboard — is reproduced either way.
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
||||
{
|
||||
// 1 is a reserved port number that no LDAP server listens on.
|
||||
@@ -109,7 +145,7 @@ public sealed class DashboardLdapLiveTests
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
"admin123",
|
||||
SharedDirectoryPassword,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
@@ -147,8 +183,10 @@ public sealed class DashboardLdapLiveTests
|
||||
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
|
||||
/// unreachable-server test's timing) at whatever value the operator configured, and
|
||||
/// cannot silently drop a field added to the shared type. The gateway's
|
||||
/// <c>appsettings.json</c> seeds the dev directory connection (localhost:3893,
|
||||
/// plaintext, AllowInsecure).
|
||||
/// <c>appsettings.json</c> seeds the dev directory connection (port 3893, plaintext,
|
||||
/// AllowInsecure) but ships <c>Server=localhost</c>, so a run against the shared GLAuth
|
||||
/// needs the <c>MxGateway__Ldap__Server=10.100.0.35</c> environment override that the
|
||||
/// <c>AddEnvironmentVariables()</c> layer below applies.
|
||||
/// </summary>
|
||||
private static LibraryLdapOptions LibraryOptions()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user