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

15 KiB

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 groupsothergroups = [...] 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 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 GwAdminAdministrator, GwReaderViewer
GroupToTag GwReaderteam-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:

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:

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:
[[groups]]
  name = "GwAdmin"
  gidnumber = 5510              # pick the next free GID
  1. 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:
[[users]]
  name = "gwadmin"
  givenname = "Gateway"
  sn = "Admin"
  mail = "gwadmin@lmxopcua.local"
  uidnumber = 5010
  primarygroup = 5510
  passsha256 = "<sha256 of the password — see below>"
  1. 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:

# Windows / PowerShell
$bytes = [System.Text.Encoding]::UTF8.GetBytes("yourpassword")
$hash  = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
-join ($hash | ForEach-Object { $_.ToString("x2") })
# WSL / git-bash
echo -n "yourpassword" | openssl dgst -sha256

Quick verification

From mxaccessgw's dev box, prove the shared directory is reachable:

# 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:

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:

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):

# 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:

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.