fix(SEC-36): scrub committed dev LDAP service-account password; add user-secrets channel + rotation runbook

Repo-side half of SEC-36. The appsettings.json plaintext was already discharged
before this branch (HEAD ships the fail-closed ${secret:ldap/mxgateway/bind}
store reference), so the residual leak was the literal value in glauth.md,
docs/GatewayTesting.md, and the historical archreview SEC-06 evidence -- all
scrubbed to <service-account-password> placeholders pointing at the source of
truth scadaproj/infra/glauth/.

- csproj: add <UserSecretsId>mxaccessgw-server</UserSecretsId> (dev channel)
- GatewayOptionsValidator: blank-password message now names both channels
  (dev user-secrets, deployed MxGateway__Ldap__ServiceAccountPassword)
- test: assert the message names both channels
- docs: GatewayConfiguration.md (three channels + rotation note), glauth.md
  (placeholders + rotation-required + runbook pointer), GatewayTesting.md
- new operator runbook docs/runbooks/SEC-36-ldap-credential-rotation.md
  (live rotation + NSSM staging remain operator-pending)
- tracking: SEC-36 -> Done (repo-side) in both registers + change-log

Deviation: kept the ${secret:} reference in appsettings.json rather than
deleting it (spec step 2 assumed the stale plaintext baseline); deleting it
would regress the shipped/documented/tested secret-store channel.

git grep -i for the old value is empty across all tracked files.
This commit is contained in:
Joseph Doherty
2026-08-07 07:40:41 -04:00
parent 10534ec906
commit 8c312c717c
11 changed files with 163 additions and 16 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`) | Not started | Committed dev LDAP service-account password: remove from repo and rotate |
| 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 |
---
@@ -218,3 +218,5 @@ dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --fil
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewayOptionsValidator"
```
(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 36 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.
+1 -1
View File
@@ -49,7 +49,7 @@ Impact: logout (`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:136-155`)
Recommendation: keep the lifetime short (or shorten to ~5 minutes given the factory refreshes per reconnect, `docs/GatewayDashboardDesign.md:497-499`), and confirm no request-path logging captures query strings (Serilog request logging is not currently enabled; keep it that way or scrub `access_token`).
**SEC-6 · Medium — LDAP is plaintext-by-default with a committed service-account password.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs:49-61` (defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword = "serviceaccount123"`), `appsettings.json:21-33` (same values checked into the repo), `glauth.md:30,327` (dev LDAPS disabled; "binding sends passwords cleartext on the wire").
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs:49-61` (defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword = "<service-account-password>"` — value redacted per SEC-36), `appsettings.json:21-33` (same values checked into the repo), `glauth.md:30,327` (dev LDAPS disabled; "binding sends passwords cleartext on the wire").
Impact: every dashboard login sends the operator's password in cleartext to `10.100.0.35:3893`, and the LDAP service-account credential is in source control. This is a documented dev posture (the shadow-options rationale at `LdapOptions.cs:20-28` is explicit that the shared library is secure-by-default), and the validator does enforce the `Transport=None ⇒ AllowInsecure` consistency rule (`GatewayOptionsValidator.cs:82-85`) — but nothing distinguishes dev from prod at runtime.
Recommendation: for production deployment docs, require `Transport=Ldaps`/`StartTls` + `AllowInsecure=false` and move `ServiceAccountPassword` to env-var/secret configuration; consider an `IsProduction` startup check mirroring SEC-4. LDAP injection risk is delegated to the shared `ZB.MOM.WW.Auth.Ldap` provider (bind-then-search per `Dashboard/DashboardAuthenticator.cs:41-47`); its escaping cannot be verified from this repo — flag for review in the donor repo.
@@ -132,7 +132,7 @@ This document turns every finding in the Security/Dashboard/Observability review
## SEC-06 — LDAP plaintext-by-default with a committed service password `Medium` · `P1`
**Finding.** *(review SEC-6)* `Configuration/LdapOptions.cs:49-61` defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword="serviceaccount123"`; `appsettings.json:21-33` ships the same. `glauth.md:30,327` confirms dev LDAPS is disabled and binds send cleartext. The validator enforces the `Transport=None ⇒ AllowInsecure` consistency rule (`GatewayOptionsValidator.cs:82-85`) but nothing distinguishes dev from prod.
**Finding.** *(review SEC-6)* `Configuration/LdapOptions.cs:49-61` defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword="<service-account-password>"` (value redacted per SEC-36); `appsettings.json:21-33` ships the same. `glauth.md:30,327` confirms dev LDAPS is disabled and binds send cleartext. The validator enforces the `Transport=None ⇒ AllowInsecure` consistency rule (`GatewayOptionsValidator.cs:82-85`) but nothing distinguishes dev from prod.
**Impact.** Every dashboard login sends the operator's password cleartext to `10.100.0.35:3893`, and a service-account credential is in source control.
@@ -140,7 +140,7 @@ This document turns every finding in the Security/Dashboard/Observability review
**Implementation.**
- `Configuration/GatewayOptionsValidator.cs`: in `ValidateLdap`, when Production and `Transport == None`, emit an error (co-locate with SEC-04's env plumbing).
- Deployment: keep `serviceaccount123` only for local GLAuth dev; document env-var override (`MxGateway__Ldap__ServiceAccountPassword`) for the NSSM-wrapped hosts; rotate the dev credential's reuse.
- Deployment: keep the dev service-account password only for local GLAuth dev; document env-var override (`MxGateway__Ldap__ServiceAccountPassword`) for the NSSM-wrapped hosts; rotate the dev credential's reuse.
- Docs: `docs/GatewayConfiguration.md` Ldap section and a production hardening note referencing `glauth.md`.
- Tests: `GatewayOptionsValidatorTests``Transport=None` + Production → invalid.
+1 -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. **No longer a committed plaintext value:** `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 now blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed the value 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. An operator may instead override it directly with the env var `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) — a plain literal there is used as-is and the secret lookup is skipped. |
| `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: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`). |
+4 -3
View File
@@ -241,14 +241,15 @@ an unreachable LDAP server is absorbed into a failed result rather than throwing
pre-host secrets expander, which this suite's bare `ConfigurationBuilder`
does not run). Before running the live LDAP suite, set
`MxGateway__Ldap__ServiceAccountPassword` to the real GLAuth service-account
password (dev value `serviceaccount123` for the shared GLAuth) so the suite
binds with the real password instead of the literal token.
password so the suite binds with the real password instead of the literal
token. Obtain the current value from the GLAuth source of truth
`scadaproj/infra/glauth/` (per `glauth.md`); it is not committed here.
Run the LDAP live tests explicitly:
```bash
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
$env:MxGateway__Ldap__ServiceAccountPassword = "serviceaccount123"
$env:MxGateway__Ldap__ServiceAccountPassword = "<service-account-password>"
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
```
@@ -0,0 +1,119 @@
# SEC-36 — LDAP Service-Account Credential Rotation (Operator Runbook)
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
load-bearing half and is yours to execute.
> **Never put the old or new password in this repo, in a commit, in a chat, or in this file.**
> The value lives only in the GLAuth source of truth and in each host's out-of-band channel.
## Why
The dev GLAuth service-account password (`cn=serviceaccount,dc=zb,dc=local`) was historically
committed to this repo. Removal alone is insufficient — the old value is permanently recoverable
from git history — so **rotation is required**. Until the shared GLAuth on `10.100.0.35:3893`
stops honoring the old value, the repo history discloses a live directory account with LDAP
search capability over `dc=zb,dc=local`.
## Where the credential lives now (three channels, all bind `MxGateway:Ldap:ServiceAccountPassword`)
- **Source of truth:** `scadaproj/infra/glauth/config.toml` on host `10.100.0.35` (the `serviceaccount`
user's `passsha256`). `scadaproj` is a shared monorepo — stage only the explicit glauth paths.
- **Encrypted secrets store (gateway default):** `appsettings.json` ships `${secret:ldap/mxgateway/bind}`,
resolved from the local encrypted store (seed with `secret set ldap/mxgateway/bind <value>`).
- **Deployed hosts:** env var `MxGateway__Ldap__ServiceAccountPassword` in the NSSM service environment.
- **Dev boxes:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>`
(the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`).
See `docs/GatewayConfiguration.md` (the `ServiceAccountPassword` row) and `glauth.md`.
## Preconditions
- SSH access to the GLAuth docker host `10.100.0.35` and to the deployed gateway host(s).
- Write access to `scadaproj/infra/glauth/`.
- Know which deployed hosts run LDAP-backed dashboard login:
- **`10.100.0.48`** (`windev`) — primary; verify here.
- **`wonder-app-vd03`** — its dashboard is disabled. **Check `MxGateway:Ldap:Enabled` there first.**
If LDAP is disabled (`Enabled=false`), it has nothing to bind and needs no env var — skip it.
- A generated replacement secret (see step 1). Generate the `passsha256` per `glauth.md`
("Generate `passsha256` from a plaintext password").
## Cutover order
Follow this order so no window opens where the deployed dashboard cannot bind. **Do not rotate
GLAuth before the deployed hosts already carry the new value.**
1. **Generate the new secret in `scadaproj/infra/glauth/`.** Pick a new password, compute its
`passsha256`, and stage the change to the `serviceaccount` user in `config.toml` (do not
`docker compose up` yet — the directory must keep honoring the OLD value until the deployed
hosts carry the NEW one).
2. **Pre-stage the NEW value on every LDAP-enabled deployed host** via the env-var channel, so the
host is ready the instant GLAuth flips:
```powershell
nssm get MxAccessGw AppEnvironmentExtra
nssm set MxAccessGw AppEnvironmentExtra MxGateway__Ldap__ServiceAccountPassword=<new-value>
# restart the service so the new environment is picked up
nssm restart MxAccessGw
```
Do this on `10.100.0.48`, and on `wonder-app-vd03` **only if** `MxGateway:Ldap:Enabled=true` there.
(Alternatively seed the encrypted store with `secret set ldap/mxgateway/bind <new-value>`; the
env var overrides the store and is the simplest per-host mechanism.)
At this moment the deployed host holds the NEW value but GLAuth still honors the OLD one — binds
still fail closed against the old directory, which is expected and brief; proceed immediately.
3. **Rotate GLAuth on `10.100.0.35`** to honor the new value:
```bash
ssh 10.100.0.35
cd ~/Desktop/scadaproj/infra/glauth
docker compose up -d --force-recreate
docker compose logs -f # confirm clean startup, no TOML parse error
```
4. **Verify dashboard login on the deployed host(s).** Browse to the gateway dashboard on
`10.100.0.48` and log in as `multi-role` / `password` (Administrator) — a successful login proves
the search bind used the new service-account credential end-to-end. If `wonder-app-vd03` runs
LDAP, verify it too; if its dashboard/LDAP is disabled, no check is needed.
5. **The repo change is already landed** (removal of the committed value, `<UserSecretsId>`, the
validator message naming the two channels, and doc/scrub updates). Nothing more to commit for
the cutover.
6. **Developers set user-secrets on next pull.** After pulling, a dev box with no secret configured
will fail startup with a validation message naming the exact command. One-time per machine:
```bash
dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <new-value>
```
(value from `scadaproj/infra/glauth/`, never from a repo file).
## Verifying the rotation
- **Primary:** dashboard `/login` as `multi-role` on `10.100.0.48` succeeds (step 4).
- **`wonder-app-vd03`:** only if `MxGateway:Ldap:Enabled=true`; otherwise no action.
- **Live-LDAP integration tests** (opt-in, only where the GLAuth instance is reachable):
```bash
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
$env:MxGateway__Ldap__ServiceAccountPassword = "<new-value>" # shell env only, never committed
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj `
--filter FullyQualifiedName~DashboardLdapLiveTests
```
A green `DashboardLdapLiveTests` run confirms the new credential binds and searches. Where GLAuth
is unreachable, document the suite as skipped per the `docs/GatewayTesting.md` opt-in matrix.
- **Old value is dead:** after step 3, a bind with the old password must fail. Do not test this from
a shared-NAT box — GLAuth's 3-fail / 10-minute per-IP lockout can lock the whole office.
## Rollback
If dashboard login breaks after step 3, restore the previous `passsha256` in
`scadaproj/infra/glauth/config.toml`, `docker compose up -d --force-recreate`, and re-point the
deployed hosts' env var / store back to the previous value. Because the deployed hosts were
pre-staged in step 2, the exposure window is only steps 2→4.
## Done criteria
- GLAuth on `10.100.0.35` honors only the new value.
- Every LDAP-enabled deployed host binds with the new value (dashboard login verified).
- The source of truth `scadaproj/infra/glauth/config.toml` carries the new `passsha256`.
- No repo file (this one included) contains the old or new value.
- The SEC-36 tracker rows are `Done` with this runbook cited for the operator action.
+14 -5
View File
@@ -30,10 +30,19 @@ gw-specific role.
| LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
| Base DN | `dc=zb,dc=local` |
| Bind DN format | `cn={username},dc=zb,dc=local` |
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` (password: `<service-account-password>`) |
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
> **Service-account password is not committed (SEC-36).** The samples below show
> `<service-account-password>` as a placeholder, not the real value. The single source of
> truth is **`scadaproj/infra/glauth/config.toml`** on host `10.100.0.35`; the gateway consumes
> 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`.
## Pre-existing groups (LmxOpcUa role taxonomy)
These map cleanly onto MxAccess capability boundaries — mxaccessgw
@@ -62,7 +71,7 @@ group below).
| `writeconfig` | `writeconfig123` | 5006 | WriteConfigure | — | + WriteSecured (Configure) |
| `alarmack` | `alarmack123` | 5003 | AlarmAck | — | Alarm acknowledgment |
| `admin` | `admin123` | 5004 | ReadOnly | WriteOperate, AlarmAck, WriteTune, WriteConfigure | All roles |
| `serviceaccount` | `serviceaccount123` | 5999 | ReadOnly | — | LDAP search capability (for bind-then-search) |
| `serviceaccount` | `<service-account-password>` | 5999 | ReadOnly | — | LDAP search capability (for bind-then-search) |
For mxaccessgw dev, `admin` covers every gw-side capability test;
`readonly` is the right "negative" case for proving Browse-OK /
@@ -100,7 +109,7 @@ by `sAMAccountName`, not `cn`. Use this only for dev convenience.
```
1. Bind as the service account (cn=serviceaccount,dc=zb,dc=local
/ serviceaccount123).
/ <service-account-password>).
2. Search under dc=zb,dc=local with filter
(uid=<entered-username>) — or any attribute the deployment
identifies users by. GLAuth populates uid + cn.
@@ -133,7 +142,7 @@ ldap:
allowInsecureLdap: true # dev only
searchBase: "dc=zb,dc=local"
serviceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
serviceAccountPassword: "serviceaccount123"
serviceAccountPassword: "<service-account-password>" # not committed; see source-of-truth note
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
displayNameAttribute: "cn"
groupAttribute: "memberOf"
@@ -242,7 +251,7 @@ Or via `ldapsearch` if you have OpenLDAP CLI tools:
```bash
ldapsearch -x -H ldap://10.100.0.35:3893 \
-D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
-D "cn=serviceaccount,dc=zb,dc=local" -w '<service-account-password>' \
-b "dc=zb,dc=local" "(uid=multi-role)"
```
@@ -145,7 +145,12 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
builder);
AddIfBlank(
options.ServiceAccountPassword,
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled. "
+ "Never commit it: on dev boxes set user-secrets "
+ "(dotnet user-secrets set \"MxGateway:Ldap:ServiceAccountPassword\" <value>); "
+ "on deployed hosts set the environment variable "
+ "MxGateway__Ldap__ServiceAccountPassword. "
+ "(appsettings.json ships the ${secret:ldap/mxgateway/bind} store reference as the default.)",
builder);
AddIfBlank(
options.UserNameAttribute,
@@ -2,6 +2,10 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- Dev-box channel for MxGateway:Ldap:ServiceAccountPassword (SEC-36): user-secrets
are loaded automatically in the Development environment and live under the user
profile, outside the tree, so the shared GLAuth bind credential is never committed. -->
<UserSecretsId>mxaccessgw-server</UserSecretsId>
</PropertyGroup>
<ItemGroup>
@@ -757,9 +757,15 @@ public sealed class GatewayOptionsValidatorTests
new LdapOptions { Enabled = true, ServiceAccountPassword = string.Empty });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
string failure = Assert.Single(
result.Failures!,
f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled."));
// SEC-36: the message must steer the operator to the two supported out-of-band channels
// (dev user-secrets, deployed env var) so a blanked/unresolved credential never gets
// "fixed" by re-committing a value.
Assert.Contains("dotnet user-secrets set", failure);
Assert.Contains("MxGateway__Ldap__ServiceAccountPassword", failure);
}
private static GatewayOptions WithSecurity(SecurityOptions security)