The per-session dashboard event ACL shipped in693a78d+7ec0b35with 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.
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 on10.100.0.35:3893(dc=zb,dc=local); the single source of truth isscadaproj/infra/glauth/(config.toml+README). The localhost/NSSM/glauth.cfgprocedures 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 (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.)C:\publish\glauth\ and run as a Windows service via
NSSM.
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 |
| 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 isscadaproj/infra/glauth/config.tomlon host10.100.0.35; the gateway consumes it out-of-band (encrypted secrets store reference${secret:ldap/mxgateway/bind}, theMxGateway__Ldap__ServiceAccountPasswordenv var on deployed hosts, ordotnet user-secretson dev boxes — seedocs/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 perdocs/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 below for the
(now-retired) per-box procedure and for the shared-config equivalent.
Dashboard role value (Task 1.7): the LDAP
GwAdmingroup now maps to the canonical dashboard roleAdministrator(wasAdmin);GwReadermaps toViewer. This is a pure value rename viaMxGateway:Dashboard:GroupToRole— same operations are authorized. (This dashboard role is distinct from the lowercase gRPCadminAPI-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:
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) andGwReader(gid 5611) are already present in the shared GLAuth. To add or modify users/groups, edit~/Desktop/scadaproj/infra/glauth/config.tomlon host10.100.0.35and run:cd ~/Desktop/scadaproj/infra/glauth docker compose up -d --force-recreateThe 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):
- Edit
C:\publish\glauth\glauth.cfg - Append the group:
[[groups]]
name = "GwAdmin"
gidnumber = 5510 # pick the next free GID
- Add
5510toadmin'sothergroupslist soadminresolves theGwAdminrole. 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>"
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 composeon10.100.0.35(scadaproj/infra/glauth/). The Windows NSSMGLAuthservice on the dev box has been stopped and set toStartupType=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.tomlare dev-only. The shared config is inscadaproj/infra/glauth/config.toml(unencrypted); restrict filesystem access on10.100.0.35accordingly. Treat the dev creds as throwaway. Production LDAP is Active Directory. (The retired per-boxC:\publish\glauth\glauth.cfghas 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.