Files
mxaccessgw/glauth.md
T
Joseph Doherty 1d8a4a6442 test(dashboard)+docs: SEC-25 live-LDAP ACL coverage; design marked implemented
The per-session dashboard event ACL shipped in 693a78d + 7ec0b35 with unit
coverage over a fabricated principal. What a fabricated principal cannot show is
that the group names the shared directory actually returns -- short RDN values,
not DNs -- are the ones Dashboard:GroupToTag keys match. Two [LiveLdapFact]s
close that: gw-viewer binds for real, its GwReader membership grants team-a, and
IDashboardSessionAcl then admits a team-a-tagged session and refuses a
team-b-tagged one; multi-role takes the Administrator bypass. The mapping is
config-side only -- no GLAuth entry, group, or membership was added, and
glauth.md records that explicitly so a future reader does not go looking for a
directory change that never happened.

multi-role is a member of GwReader as well as GwAdmin, so it holds team-a too.
Its bypass is therefore asserted on team-b and on the untagged session -- the two
it would lose if the Administrator branch were ever dropped -- rather than on
team-a, which would pass either way.

One cheap hardening from a prior review: a GatewayOptionsTests case binds
Dashboard:GroupToTag through a real ConfigurationBuilder and looks the group up
mis-cased. The property initializer seeds an OrdinalIgnoreCase dictionary, but
only the binder decides whether that instance survives; if it did not, a
mis-cased group name from the directory would grant no tags and the ACL would
deny with no diagnostic.

Docs follow the shipped shape: docs/Sessions.md gains the session-tag model
(owner-key sourced, immutable, visibility-not-access), gateway.md and CLAUDE.md
gain the ACL in their dashboard-auth paragraphs, and three
GatewayDashboardDesign.md passages that still described the ACL as outstanding
now describe both gated seams and the decision order. GatewayConfiguration.md's
ShowTagValues row no longer claims the redaction is the only thing between a
Viewer and another session's values -- it is now the second of two independent
layers. gateway.md's hub-token lifetime corrected 30 minutes -> 5, matching
HubTokenService. Authentication.md disambiguates --dashboard-tags as the only
constraint flag that splits on commas. The plan doc header is Implemented; its
as-built section 12 already existed and is not duplicated.

Verified: NonWindows.slnx builds clean; GatewayOptions/DashboardSessionAcl/
EventsHub filters 37/37; the live-LDAP suite skips cleanly without the env var
and runs 7/7 green against the shared GLAuth with it.
2026-08-17 04:48:34 -04:00

361 lines
15 KiB
Markdown

# GLAuth — LDAP authn reference for mxaccessgw
> **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
> Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
> the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
> The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
that directory so a single set of dev credentials covers all stacks.
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
kept only as a rollback option. Do not edit or restart it for new work.)*
The single source of truth for the shared GLAuth is
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
what users + groups are provisioned, how to bind against them, and what's needed to add a
gw-specific role.
## Connection details
| Setting | Value |
|---|---|
| Protocol | LDAP (unencrypted) |
| Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
| Port | `3893` |
| 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` (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); 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)
These map cleanly onto MxAccess capability boundaries — mxaccessgw
should reuse them rather than define parallel groups so an operator with
LmxOpcUa write rights doesn't need a second account for the gw.
| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping |
|---|---|---|---|---|
| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=zb,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) |
| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=zb,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=zb,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=zb,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=zb,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
**A user can be in multiple groups**`othergroups = [...]` in the
config is a list. `admin` is the canonical example (in every role
group below).
## Pre-provisioned users
| Username | Password | UID | Primary group | Other groups | Capabilities |
|---|---|---|---|---|---|
| `readonly` | `readonly123` | 5001 | ReadOnly | — | Browse, read |
| `writeop` | `writeop123` | 5002 | WriteOperate | — | + plain Write |
| `writetune` | `writetune123` | 5005 | WriteTune | — | + WriteSecured (Tune) |
| `writeconfig` | `writeconfig123` | 5006 | WriteConfigure | — | + WriteSecured (Configure) |
| `alarmack` | `alarmack123` | 5003 | AlarmAck | — | Alarm acknowledgment |
| `admin` | `admin123` | 5004 | ReadOnly | WriteOperate, AlarmAck, WriteTune, WriteConfigure | All roles |
| `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 /
Write-denied.
The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
`GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
The dashboard test users are **`multi-role`/`password`** (Administrator) and
**`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
(now-retired) per-box procedure and for the shared-config equivalent.
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
> maps to `Viewer`. This is a pure value rename via
> `MxGateway:Dashboard:GroupToRole` — same operations are authorized. (This
> dashboard role is distinct from the lowercase gRPC `admin` *API-key scope*.)
### Dashboard visibility tags in the live tests
`DashboardLdapLiveTests` covers the per-session dashboard event ACL (SEC-25) against this
directory. **No GLAuth change was needed, and none was made** — the tag layer is entirely
config-side, so the fixture simply names groups that already exist:
| Fixture `MxGateway:Dashboard` setting | Value |
| --- | --- |
| `GroupToRole` | `GwAdmin``Administrator`, `GwReader``Viewer` |
| `GroupToTag` | `GwReader``team-a` |
| `UntaggedSessionVisibility` | `AdminOnly` (the shipped default, stated explicitly because the assertions read it) |
`team-a` and `team-b` are operator-chosen labels that exist only in the test's configuration
and on its in-memory sessions; nothing in the directory carries them. `gw-viewer` therefore
logs in as a Viewer granted `team-a` and is admitted to a `team-a`-tagged session but refused a
`team-b`-tagged one. `multi-role` is a member of **both** `GwAdmin` and `GwReader`, so this map
grants it `team-a` as well — its `team-a` allow would hold even without the Administrator
bypass, which is why the bypass is asserted on the `team-b` and untagged sessions instead.
What only a live bind proves here is that the group names `ILdapAuthService` returns from this
directory (short RDN values, not DNs) are the ones `GroupToTag` keys match; a fabricated
principal cannot show that.
## Two bind patterns
### 1. Direct bind (simplest)
```
DN: cn=admin,dc=zb,dc=local
Password: admin123
```
Construct the DN from the username; bind. Works on GLAuth because
`backend.nameformat = "cn"` and `groupformat = "ou"` are set in the
config. **Doesn't translate to Active Directory** — AD users are keyed
by `sAMAccountName`, not `cn`. Use this only for dev convenience.
### 2. Bind-then-search (production-grade)
```
1. Bind as the service account (cn=serviceaccount,dc=zb,dc=local
/ <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.
3. Read the returned entry's DN + memberOf list (groups).
4. Bind again as the discovered DN with the entered password. If that
succeeds, authn passes; the memberOf values become the role set.
```
The second bind is the actual password check — the search is just a DN
discovery. This is the AD-friendly path: AD's
`tokenGroups` / `LDAP_MATCHING_RULE_IN_CHAIN` flatten nested groups, but
that's an enhancement, not required for first-pass dev.
LmxOpcUa's `Server/Security/LdapUserAuthenticator.cs` ships a working
implementation of this pattern using `Novell.Directory.Ldap.NETStandard`
v3.6.0 — copy the bind-then-search loop from there if mxaccessgw wants
to avoid re-deriving the LDAP escape-string handling.
## Suggested mxgw configuration shape
A YAML/JSON section for mxaccessgw that mirrors LmxOpcUa's `LdapOptions`
record:
```yaml
ldap:
enabled: true
server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
port: 3893
useTls: false
allowInsecureLdap: true # dev only
searchBase: "dc=zb,dc=local"
serviceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
serviceAccountPassword: "<service-account-password>" # not committed; see source-of-truth note
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
displayNameAttribute: "cn"
groupAttribute: "memberOf"
groupToRole:
ReadOnly: "Browse"
WriteOperate: "Write"
WriteTune: "WriteSecured"
WriteConfigure: "WriteSecured"
AlarmAck: "AlarmAck"
```
`groupAttribute` returns full DNs like
`ou=ReadOnly,ou=groups,dc=zb,dc=local` — the authenticator
should strip the leading `ou=` (or `cn=` against AD) RDN value and
look that up in `groupToRole`.
## Provisioning the GwAdmin group
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
>
> ```bash
> cd ~/Desktop/scadaproj/infra/glauth
> docker compose up -d --force-recreate
> ```
>
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
> rollback reference only — do not use it for new provisioning.
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
logins unless the user is a member of `GwAdmin`.
The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
`multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
---
**RETIRED — per-box provisioning (reference/rollback only):**
1. Edit `C:\publish\glauth\glauth.cfg`
2. Append the group:
```toml
[[groups]]
name = "GwAdmin"
gidnumber = 5510 # pick the next free GID
```
3. Add `5510` to `admin`'s `othergroups` list so `admin` resolves the
`GwAdmin` role. Add it to any other user that needs dashboard-admin
rights. Or create a dedicated user:
```toml
[[users]]
name = "gwadmin"
givenname = "Gateway"
sn = "Admin"
mail = "gwadmin@lmxopcua.local"
uidnumber = 5010
primarygroup = 5510
passsha256 = "<sha256 of the password — see below>"
```
4. `nssm restart GLAuth`
After the restart, `admin`'s `memberOf` includes
`ou=GwAdmin,ou=groups,dc=zb,dc=local`, which the authenticator
strips to `GwAdmin` and matches against `RequiredGroup`. The same
pattern applies to any future permission that doesn't fit the existing
five roles.
Generate `passsha256` from a plaintext password:
```powershell
# Windows / PowerShell
$bytes = [System.Text.Encoding]::UTF8.GetBytes("yourpassword")
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
-join ($hash | ForEach-Object { $_.ToString("x2") })
```
```bash
# WSL / git-bash
echo -n "yourpassword" | openssl dgst -sha256
```
## Quick verification
From mxaccessgw's dev box, prove the shared directory is reachable:
```powershell
# Plain bind via PowerShell + System.DirectoryServices.Protocols
# (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
$ldap.SessionOptions.ProtocolVersion = 3
$ldap.SessionOptions.SecureSocketLayer = $false
$cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
$ldap.Bind($cred)
"Bind OK"
```
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 '<service-account-password>' \
-b "dc=zb,dc=local" "(uid=multi-role)"
```
The response should list `multi-role`'s entry with `memberOf` including
`ou=GwAdmin,ou=groups,dc=zb,dc=local`.
## Service management
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
>
> **Active (shared) management:**
> ```bash
> ssh 10.100.0.35
> cd ~/Desktop/scadaproj/infra/glauth
> docker compose ps # check container status
> docker compose up -d --force-recreate # apply config.toml changes
> docker compose logs -f # tail logs
> ```
**RETIRED — per-box NSSM commands (rollback reference):**
```powershell
# Status / start / stop / restart
nssm status GLAuth
nssm start GLAuth
nssm stop GLAuth
nssm restart GLAuth
# Inspect what NSSM was told to launch
nssm get GLAuth Parameters
```
Logs:
| File | Purpose |
|---|---|
| `C:\publish\glauth\logs\stdout.log` | Bind events, search responses |
| `C:\publish\glauth\logs\stderr.log` | Startup errors, config parse failures |
After editing `glauth.cfg`, always tail `stderr.log` after the restart
to catch a fat-fingered TOML before it bites at first bind:
```powershell
nssm restart GLAuth
Get-Content C:\publish\glauth\logs\stderr.log -Tail 20 -Wait
```
## Active Directory migration cheat-sheet
LmxOpcUa's `LdapOptions` xml-doc captures the AD overrides; same set
applies to mxaccessgw verbatim. Keys that change:
| Field | GLAuth dev value | AD production value |
|---|---|---|
| `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
| `UseTls` | `false` | `true` |
| `AllowInsecureLdap` | `true` | `false` |
| `SearchBase` | `dc=zb,dc=local` | `DC=corp,DC=example,DC=com` |
| `ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` |
| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) |
| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) |
`memberOf` returns full DNs; the authenticator strips the leading
`CN=` value and uses it as the lookup key in `groupToRole`. Nested
groups are **not** auto-expanded; either flatten in the directory or
add a `tokenGroups` query as an enhancement.
## Security notes for production
- **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
`scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
`10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
- The 3-fail / 10-minute lockout is per source IP, not per user — a
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
expose port 3893 externally without enabling TLS first.