Compare commits

...

8 Commits

Author SHA1 Message Date
Joseph Doherty 129e47e541 docs(tracking): close NEXT-07, file NEXT-08/09/10, record the runner token reset
NEXT-07 is struck: windev was redeployed from origin/main (a346d51) and the service is
healthy, and the root cause the row predicted is confirmed -- the 2026-06-25 build's
Auth.ApiKeys 0.1.2.0 supports auth-DB schema 2 while the database sits at schema 3, which
is the current shared-lib version, so deploying forward was the fix rather than touching
the DB. The original text stays for the triage record.

Three findings surfaced by that work, each deliberately left for the next cycle rather
than patched in passing:

- NEXT-08: the shared GLAuth offers no TLS, so SEC-06 makes GatewayConfiguration.md's
  "deployed hosts must set Ldaps or StartTls" unsatisfiable for anything genuinely
  labelled Production. windev's relabel to Staging is honest for a dev rig but defers
  the posture question rather than answering it.
- NEXT-09: Directory.Build.props:29 quotes a path ending in a backslash, so the SHA-stamp
  git invocation is malformed on Windows and ContinueOnError stamps git's stderr into
  InformationalVersion -- a Windows binary cannot be correlated to a commit, which is what
  TST-11 exists to guarantee.
- NEXT-10: glauth.md's pre-provisioned-user table contradicts both the directory and its
  own dashboard section, and was the root cause of the NEXT-06 fixture drift. Reconciling
  it sweeps the OPC-UA group taxonomy, so it is scoped out here on purpose.

The TST-30 runner work is hygiene, not closure: runner-1 now mounts its registration token
from a 0600 file like runner-2, but both still share one instance-scope token that was
world-readable for months and is provably still live. Gitea 1.26.4 cannot rotate it from
the CLI or API, so the UI reset is recorded as a pending operator action with its
follow-through (refresh the token file, shred the token-bearing compose backups).
2026-08-07 10:31:29 -04:00
Joseph Doherty 1d6858939d docs(sec-36): record the completed windev dashboard verification
SEC-36's primary check -- dashboard /login through the real DashboardAuthenticator
search bind -- was deferred because windev's gateway was crash-looping on the stale
deployment filed as NEXT-07. That host was redeployed 2026-08-07, so the check ran:
login as multi-role returns 302 with the dashboard cookie and the authenticated page
renders the admin nav, while an anonymous control still redirects to /login. The
rotated service-account credential is now proven end-to-end on the deployed host, not
only by the equivalent ldapsearch primitive, and the runbook's Correction 3 is past
tense throughout rather than describing a fault that no longer exists.

Also record why windev runs the Staging environment name. The redeploy tripped SEC-06's
Production hard-stop on Ldap:Transport=None, and windev cannot satisfy it: it binds the
shared GLAuth, which offers no TLS, and runs Dashboard:DisableLogin=true. The Production
label contradicted its own configuration, so the host was relabelled rather than the
guard weakened -- exactly the permissive-staging-rig case the SEC-35 section already
carves out.
2026-08-07 10:31:15 -04:00
Joseph Doherty de67b45d04 test(ldap): align DashboardLdapLiveTests fixtures with the shared directory (NEXT-06)
The suite's fixtures had drifted from the shared GLAuth config, so a green run
proved nothing about the service-account bind: the only success-path test used
admin/admin123, but the directory's admin carries the standard dev password, and
the "not an admin" test used a readonly user that does not exist there at all --
it passed via the user-not-found branch rather than the group-missing branch it
names.

Realign to real users from scadaproj/infra/glauth/config.toml: admin/password
(othergroups include GwAdmin, gid 5610) for the success path, and
gw-viewer/password (GwReader only, gid 5611) for the bind-succeeds-but-no-role
path. Both are published dev credentials documented in glauth.md, not secrets.

The gw-viewer test drops its old no-leak assertion on the credential literal:
the real password is the word "password", which legitimately occurs in the
generic denial text, so the check would fail for the wrong reason. The no-leak
property is still covered with a distinctive literal by the wrong-password test.
In its place the test now asserts the property this fixture is uniquely able to
prove -- an authorization failure must be reported with the same message as an
authentication failure, so it cannot be used to enumerate valid accounts.

appsettings ships Server=localhost, so document the MxGateway__Ldap__Server
override the suite needs to reach the shared GLAuth alongside the existing
MXGATEWAY_RUN_LIVE_LDAP_TESTS and ServiceAccountPassword variables.

Verified live: Failed: 0, Passed: 5 against 10.100.0.35:3893.
2026-08-07 10:03:10 -04:00
Joseph Doherty 3d991d2160 docs(tst-30): remove mislabelled macOS instance runner (id 4)
The local act_runner on this Mac registered as instance runner id 4 with
ubuntu-latest/22.04/20.04 labels, so it competed with the two docker
runners on 10.100.0.35 for Linux jobs it had no Docker daemon to run --
13 of the last 20-run window in historiangw landed on it and all but one
failed. Registration deleted; local config kept disabled for re-use with
mac-specific labels.
2026-08-07 10:02:29 -04:00
Joseph Doherty 05667169eb docs: sync runner-topology and SEC-36 rotation prose with 2026-08-07 executed state 2026-08-07 09:28:49 -04:00
Joseph Doherty 9760497d66 docs(sec-36): rotation executed 2026-08-07; runbook host-path/vd03/verification corrections; new findings (LDAP test fixtures, windev stale deploy) 2026-08-07 09:21:54 -04:00
Joseph Doherty 5b153dac74 docs(clients): record 2026-08-07 publish of 0.2.0 client family (Java 0.2.1); cargo token needs Bearer prefix 2026-08-07 09:15:47 -04:00
Joseph Doherty 41e86481e2 docs(tst-30): second runner gitea-runner-2 live; close operator action 2026-08-07 09:12:00 -04:00
11 changed files with 231 additions and 47 deletions
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.
+18
View File
@@ -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
+9 -1
View File
@@ -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
View File
@@ -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
+35
View File
@@ -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
+3 -2
View File
@@ -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()
{