1d8a4a6442
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.
361 lines
15 KiB
Markdown
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.
|