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