Files
scadaproj/docs/plans/2026-06-04-shared-glauth-standardization.md
T
Joseph Doherty 0f2b2b8351 feat(glauth): merged shared dev GLAuth directory + compose + runbook (10.100.0.35)
Phase 0 of the shared-GLAuth standardization. config.toml = merged dc=zb,dc=local
directory (15 groups in partitioned 55xx/56xx/57xx families, 14 users incl.
multi-role spanning all groups, serviceaccount search account). compose runs one
glauth/glauth:latest on :3893. README is the deploy/verify runbook. Code-reviewed;
fixed scp -r idempotency in the deploy command (README + plan Task 4).
2026-06-04 15:45:41 -04:00

611 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Shared GLAuth Standardization — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Consolidate OtOpcUa, MxAccessGateway, and ScadaBridge **dev/test** auth onto one shared GLAuth directory at `10.100.0.35:3893` (`dc=zb,dc=local`, plaintext), replacing the three separate LDAP setups.
**Architecture:** A single app-neutral GLAuth `config` directory lives in `scadaproj/infra/glauth/` (source of truth) and runs as one container on the shared Docker host `10.100.0.35`. Group families are partitioned into non-overlapping gid ranges (`SCADA-*` 55xx, OPC-perm/`Gw*` 56xx, `OtOpcUa-*` 57xx); each app maps only its own family. Every dev consumer just repoints its LDAP `Server` at `10.100.0.35`. Rollout is incremental and keeps the old glauths running until each consumer is verified.
**Tech Stack:** GLAuth (`glauth/glauth:latest`, TOML `config` datastore), Docker Compose / OrbStack (Mac) + Docker on `10.100.0.35`, .NET 10 apps using the shared `ZB.MOM.WW.Auth.Ldap` (search-then-bind), MSSQL config DBs, Windows/NSSM services on windev (`10.100.0.48`), `ldapsearch` + Chrome (macbook) for verification.
**Design:** [`2026-06-04-shared-glauth-standardization-design.md`](2026-06-04-shared-glauth-standardization-design.md)
**Reference values**
- `password` → sha256 `5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8`
- `serviceaccount123` → sha256 `af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419`
- Shared service account: `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`
- All consumer LDAP keys: `Server=10.100.0.35 Port=3893 Transport=None AllowInsecure=true SearchBase=dc=zb,dc=local`
**Branching:** scadaproj artifacts on the current `docs/shared-glauth-standardization` branch. Per-app config edits on a `feat/shared-glauth` branch in each app repo (ScadaBridge, OtOpcUa). windev edits are deployment-only (`.bak` backups), repo templates optionally aligned. Merge on the user's go.
**Operational caveats (read first):**
- **`10.100.0.35` access is currently blocked from this Mac** (SSH refused; windev→35 jump prohibited). **Task 4 is a hard gate** — it needs either this Mac's key re-authorized on 35 *or* the user to run the `docker compose up`. The artifact is portable.
- Tasks that recreate running clusters (ScadaBridge, OtOpcUa) and touch the **live windev host** are operational; their "tests" are `ldapsearch`/`curl`/browser checks with exact expected output. Sequence cluster recreates seed-first to avoid Akka split-brain.
---
## Phase 0 — Author + deploy the shared GLAuth
### Task 0: Write the merged GLAuth `config.toml`
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2
**Files:**
- Create: `/Users/dohertj2/Desktop/scadaproj/infra/glauth/config.toml`
**Step 1: Write the file** with this exact content (merged `dc=zb,dc=local` directory; gid families partitioned; `multi-role` is in every group):
```toml
[ldap]
enabled = true
listen = "0.0.0.0:3893"
[ldaps]
enabled = false
[backend]
datastore = "config"
baseDN = "dc=zb,dc=local"
[behaviors]
# Dev: do not lock out on failed binds (avoids surprises during testing).
LimitFailedBinds = false
# ── Groups ───────────────────────────────────────────────────────────
# ScadaBridge role groups (55xx) — DB-mapped (LdapGroupMappings)
[[groups]]
name = "SCADA-Admins"
gidnumber = 5501
[[groups]]
name = "SCADA-Designers"
gidnumber = 5502
[[groups]]
name = "SCADA-Deploy-All"
gidnumber = 5503
[[groups]]
name = "SCADA-Deploy-SiteA"
gidnumber = 5504
[[groups]]
name = "SCADA-Viewers"
gidnumber = 5505
# OPC-UA permission groups (560x) — OtOpcUa + MxGateway OPC write model
[[groups]]
name = "ReadOnly"
gidnumber = 5601
[[groups]]
name = "WriteOperate"
gidnumber = 5602
[[groups]]
name = "WriteTune"
gidnumber = 5603
[[groups]]
name = "WriteConfigure"
gidnumber = 5604
[[groups]]
name = "AlarmAck"
gidnumber = 5605
# MxGateway dashboard groups (561x) — config-mapped (GroupToRole)
[[groups]]
name = "GwAdmin"
gidnumber = 5610
[[groups]]
name = "GwReader"
gidnumber = 5611
# OtOpcUa AdminUI role groups (57xx) — DB-mapped (LdapGroupRoleMapping)
[[groups]]
name = "OtOpcUa-Admins"
gidnumber = 5701
[[groups]]
name = "OtOpcUa-Designers"
gidnumber = 5702
[[groups]]
name = "OtOpcUa-Viewers"
gidnumber = 5703
# ── Users ────────────────────────────────────────────────────────────
# All passwords are "password" except serviceaccount ("serviceaccount123").
# sha256("password") = 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
# sha256("serviceaccount123") = af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419
# The single bind account every app uses (search-then-bind).
[[users]]
name = "serviceaccount"
uidnumber = 5999
primarygroup = 5601
passsha256 = "af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419"
[[users.capabilities]]
action = "search"
object = "*"
# Cross-app: member of EVERY group → all roles in all three apps.
[[users]]
name = "multi-role"
givenname = "Multi"
sn = "Role"
mail = "multi-role@zb.local"
uidnumber = 5005
primarygroup = 5501
othergroups = [5502, 5503, 5504, 5505, 5601, 5602, 5603, 5604, 5605, 5610, 5611, 5701, 5702, 5703]
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
# Administrator everywhere (admin-equivalent of each app).
[[users]]
name = "admin"
uidnumber = 5001
primarygroup = 5501
othergroups = [5610, 5701]
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
# ScadaBridge single-role testers
[[users]]
name = "designer"
uidnumber = 5002
primarygroup = 5502
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "deployer"
uidnumber = 5003
primarygroup = 5503
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "site-deployer"
uidnumber = 5004
primarygroup = 5504
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
# MxGateway dashboard Viewer tester
[[users]]
name = "gwreader"
uidnumber = 5106
primarygroup = 5611
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
# OPC-UA permission testers
[[users]]
name = "readonly"
uidnumber = 5101
primarygroup = 5601
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "writeop"
uidnumber = 5102
primarygroup = 5602
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "writetune"
uidnumber = 5103
primarygroup = 5603
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "writeconfig"
uidnumber = 5104
primarygroup = 5604
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "alarmack"
uidnumber = 5105
primarygroup = 5605
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
# OtOpcUa single-role testers (admin covers OtOpcUa-Admins)
[[users]]
name = "otdesigner"
uidnumber = 5202
primarygroup = 5702
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "otviewer"
uidnumber = 5203
primarygroup = 5703
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
```
**Step 2: Verify TOML parses** (sanity, no network):
Run: `python3 -c "import tomllib,sys; tomllib.load(open('/Users/dohertj2/Desktop/scadaproj/infra/glauth/config.toml','rb')); print('OK')"`
Expected: `OK`
---
### Task 1: Write the GLAuth `docker-compose.yml`
**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 0, Task 2
**Files:**
- Create: `/Users/dohertj2/Desktop/scadaproj/infra/glauth/docker-compose.yml`
**Step 1: Write** (single service, bind-mount the config read-only, publish 3893 on all interfaces so cross-host clients reach it):
```yaml
# Shared dev GLAuth for OtOpcUa + MxAccessGateway + ScadaBridge.
# Deploy on the shared Docker host 10.100.0.35: docker compose up -d
# Verify: ldapsearch -x -H ldap://10.100.0.35:3893 \
# -D cn=serviceaccount,dc=zb,dc=local -w serviceaccount123 \
# -b dc=zb,dc=local "(cn=multi-role)" memberOf
name: zb-shared-glauth
services:
glauth:
image: glauth/glauth:latest
container_name: zb-shared-glauth
restart: unless-stopped
ports:
- "3893:3893"
volumes:
- ./config.toml:/app/config/config.cfg:ro
```
---
### Task 2: Write the `README.md` (deploy + verify runbook)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 0, Task 1
**Files:**
- Create: `/Users/dohertj2/Desktop/scadaproj/infra/glauth/README.md`
**Step 1: Write** a runbook covering: purpose (shared dev directory for all 3 apps); the merged directory's group families + gid ranges + the canonical users (`multi-role`/`admin`/`serviceaccount` + per-role testers); **deploy on `10.100.0.35`** (`scp -r infra/glauth dohertj2@10.100.0.35:~/zb-glauth && ssh dohertj2@10.100.0.35 'cd ~/zb-glauth && docker compose up -d'`) with the note that this Mac's SSH access to 35 must be working (else the user runs it); and the **verification** `ldapsearch` commands (bind `serviceaccount`, confirm `multi-role`'s `memberOf` spans all four families; bind each tester). Include the "to add a user/group, edit `config.toml` and `docker compose up -d --force-recreate` (the single-file bind-mount needs a recreate, not a restart)" gotcha.
---
### Task 3: Commit Phase 0 artifacts
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
**Files:** (commit only) — `/Users/dohertj2/Desktop/scadaproj/infra/glauth/*`
**Step 1:** From `/Users/dohertj2/Desktop/scadaproj` (already on `docs/shared-glauth-standardization`):
```bash
git add infra/glauth/config.toml infra/glauth/docker-compose.yml infra/glauth/README.md
git commit -m "feat(glauth): merged shared dev GLAuth directory + compose + runbook (10.100.0.35)"
```
---
### Task 4: Deploy to `10.100.0.35` and verify the directory ⟵ HARD GATE / ACCESS-PREREQUISITE
**Classification:** high-risk
**Estimated implement time:** ~5 min (blocked on 35 access)
**Parallelizable with:** none
**Files:** none (operational)
**Step 1: Resolve access.** Confirm `ssh dohertj2@10.100.0.35 'echo ok'` works. If it does NOT (currently the case from this Mac), STOP and either (a) have the user re-authorize this Mac's key on 35, or (b) hand the user `infra/glauth/` + the deploy command to run on 35. Do not proceed past this gate until GLAuth is up on 35.
**Step 2: Deploy** (once access works). Copy the FILES into the dest dir (not the dir itself) so a
re-deploy doesn't nest them at `~/zb-glauth/glauth/` (the `scp -r dir-into-existing-dir` trap):
```bash
ssh dohertj2@10.100.0.35 'mkdir -p ~/zb-glauth'
scp /Users/dohertj2/Desktop/scadaproj/infra/glauth/config.toml \
/Users/dohertj2/Desktop/scadaproj/infra/glauth/docker-compose.yml \
dohertj2@10.100.0.35:~/zb-glauth/
ssh dohertj2@10.100.0.35 'cd ~/zb-glauth && docker compose up -d --force-recreate && docker ps --filter name=zb-shared-glauth'
```
Expected: `zb-shared-glauth` container `Up`.
**Step 3 (test): Verify the directory** from the Mac via a throwaway ldap client:
```bash
docker run --rm alpine:3.20 sh -c 'apk add --no-progress -q openldap-clients >/dev/null 2>&1 && \
ldapsearch -x -H ldap://10.100.0.35:3893 -D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
-b "dc=zb,dc=local" "(cn=multi-role)" memberOf'
```
Expected: `result: 0 Success` and `memberOf` listing all four families — `SCADA-*`, `ReadOnly/Write*/AlarmAck`, `GwAdmin/GwReader`, `OtOpcUa-*`.
**Step 4 (test): Confirm a user binds with `password`:**
```bash
docker run --rm alpine:3.20 sh -c 'apk add --no-progress -q openldap-clients >/dev/null 2>&1 && \
ldapsearch -x -H ldap://10.100.0.35:3893 -D "cn=multi-role,dc=zb,dc=local" -w password \
-b "dc=zb,dc=local" "(cn=multi-role)" cn 2>&1 | grep -i "result:"'
```
Expected: `result: 50 Insufficient access` (bind OK — search denied because multi-role lacks the search capability; a *bad* password would give `result: 49`).
---
## Phase 1 — ScadaBridge repoint (Mac docker)
### Task 5: Repoint the 4 ScadaBridge central-node configs
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 10, Task 11, Task 14, Task 15 (different repos/hosts)
**Files (4 identical edits):**
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/docker/central-node-a/appsettings.Central.json` (`Ldap` block ~lines 2532)
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/docker/central-node-b/appsettings.Central.json`
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/docker-env2/central-node-a/appsettings.Central.json`
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/docker-env2/central-node-b/appsettings.Central.json`
**Step 1:** In each file's `Ldap` block, change three keys (leave `Port`, `Transport`, `AllowInsecure`, `SearchBase` as-is — already `3893`/`None`/`true`/`dc=zb,dc=local`):
- `"Server": "scadabridge-ldap"``"Server": "10.100.0.35"`
- `"ServiceAccountDn": "cn=admin,dc=zb,dc=local"``"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local"`
- `"ServiceAccountPassword": "password"``"ServiceAccountPassword": "serviceaccount123"`
**Step 2 (test): Confirm all four files updated:**
Run: `grep -l '"Server": "10.100.0.35"' /Users/dohertj2/Desktop/ScadaBridge/docker*/central-node-*/appsettings.Central.json | wc -l`
Expected: `4`
---
### Task 6: Retire the `scadabridge-ldap` service + prove OrbStack→35 reachability
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 5
**Files:**
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/infra/docker-compose.yml` (the `ldap:` service, lines ~4450)
**Step 1 (test FIRST — the linchpin): Verify a container on `scadabridge-net` can reach `10.100.0.35:3893`** before retiring anything:
```bash
docker run --rm --network scadabridge-net alpine:3.20 sh -c \
'apk add --no-progress -q openldap-clients >/dev/null 2>&1 && \
ldapsearch -x -H ldap://10.100.0.35:3893 -D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 -b "dc=zb,dc=local" "(cn=admin)" cn 2>&1 | grep -i "result:"'
```
Expected: `result: 0 Success`. **If unreachable, STOP** — fix networking (OrbStack→LAN) before repointing; do not retire the local glauth.
**Step 2:** Comment out (do not delete — keep for rollback) the `ldap:` service block in `infra/docker-compose.yml`. Stop the old container: `docker stop scadabridge-ldap`. (Leave it stopped, not removed, until Phase 4.)
---
### Task 7: Recreate the `:9000` cluster central nodes + browser-verify
**Classification:** high-risk
**Estimated implement time:** ~5 min (operational)
**Parallelizable with:** none
**Files:** none (operational)
**Step 1:** Recreate the two central nodes to pick up the new config (seed-first to avoid split-brain — recreate `central-a`, wait healthy, then `central-b`):
```bash
cd /Users/dohertj2/Desktop/ScadaBridge/docker && docker compose up -d --force-recreate --no-deps central-node-a
# wait until central-a is serving, then:
docker compose up -d --force-recreate --no-deps central-node-b
```
**Step 2 (test): Token endpoint shows all four roles** (re-runs the full LDAP auth against 35):
```bash
curl -s -m10 -X POST http://localhost:9000/auth/token --data-urlencode username=multi-role --data-urlencode password=password
```
Expected JSON contains `"roles":["Administrator","Designer","Deployer","Viewer"]`.
**Step 3 (test): Browser** (Chrome macbook) — sign out, log in `multi-role`/`password` at `http://localhost:9000/login`; expect the dashboard with ADMIN + DESIGN + DEPLOYMENT nav sections.
---
### Task 8: Recreate the `:9100` cluster central nodes + verify
**Classification:** high-risk
**Estimated implement time:** ~5 min (operational)
**Parallelizable with:** none
**Files:** none (operational)
**Step 1:** As Task 7 but in `/Users/dohertj2/Desktop/ScadaBridge/docker-env2` (recreate `central-node-a` then `-b`).
**Step 2 (test):** `curl -s -m10 -X POST http://localhost:9100/auth/token --data-urlencode username=multi-role --data-urlencode password=password``"roles":["Administrator","Designer","Deployer","Viewer"]`.
---
### Task 9: Commit ScadaBridge edits on a branch
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
**Files:** (commit) the 4 central-node json + `infra/docker-compose.yml`
**Step 1:**
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git checkout -b feat/shared-glauth
git add docker/central-node-*/appsettings.Central.json docker-env2/central-node-*/appsettings.Central.json infra/docker-compose.yml
git commit -m "feat(auth): point dev clusters at shared GLAuth 10.100.0.35; retire local scadabridge-ldap"
```
(Do not merge/push — wait for the user's go.)
---
## Phase 2 — OtOpcUa docker-dev un-stub (Mac docker)
### Task 10: Confirm group-key shape, then add `LdapGroupRoleMapping` seed rows
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 14, Task 15
**Files:**
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/docker-dev/seed/seed-clusters.sql`
- Read (gate): `/Users/dohertj2/Desktop/scadaproj/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs`
**Step 1 (gate): Confirm the runtime group string is the bare RDN** (`OtOpcUa-Admins`), not a full DN. Read `LdapAuthService.cs` and find where it builds the returned `Groups` from `memberOf`; confirm it strips each DN to its first RDN *value*. Cross-check: ScadaBridge's DB mappings use bare `SCADA-Admins` and work today against the same glauth `groupformat=ou` (so memberOf is `ou=SCADA-Admins,...` → returned as `SCADA-Admins`). Conclusion to lock: seed `LdapGroup = 'OtOpcUa-Admins'` (bare). If the code instead returns full DNs, STOP and seed the full DN form — but the evidence says bare.
**Step 2:** Append idempotent INSERTs to `seed-clusters.sql` (table `dbo.LdapGroupRoleMapping`; `Role` stored as the enum NAME string; system-wide rows ⇒ `ClusterId = NULL`, `IsSystemWide = 1`):
```sql
-- Shared-GLAuth dev: OtOpcUa AdminUI role mappings (system-wide).
-- Group keys are the BARE RDN names the shared ZB.MOM.WW.Auth.Ldap returns.
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Admins' AND ClusterId IS NULL)
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
VALUES (NEWID(), 'OtOpcUa-Admins', 'Administrator', NULL, 1, SYSUTCDATETIME(), 'shared-glauth dev seed');
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Designers' AND ClusterId IS NULL)
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
VALUES (NEWID(), 'OtOpcUa-Designers', 'Designer', NULL, 1, SYSUTCDATETIME(), 'shared-glauth dev seed');
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Viewers' AND ClusterId IS NULL)
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
VALUES (NEWID(), 'OtOpcUa-Viewers', 'Viewer', NULL, 1, SYSUTCDATETIME(), 'shared-glauth dev seed');
```
---
### Task 11: Un-stub the OtOpcUa docker-dev host containers
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5, Task 14, Task 15
**Files:**
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/docker-dev/docker-compose.yml` (the 6 admin/site containers: `admin-a` ~L100, `admin-b` ~L117, `site-a-1` ~L170, `site-a-2` ~L193, `site-b-1` ~L215, `site-b-2` ~L238)
**Step 1:** In each of the 6 containers' `environment:`, replace the single `Security__Ldap__DevStubMode: "true"` line with the real-LDAP block:
```yaml
Security__Ldap__Enabled: "true"
Security__Ldap__DevStubMode: "false"
Security__Ldap__Server: "10.100.0.35"
Security__Ldap__Port: "3893"
Security__Ldap__Transport: "None"
Security__Ldap__AllowInsecure: "true"
Security__Ldap__SearchBase: "dc=zb,dc=local"
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
```
(Driver-only `driver-a`/`driver-b` have no LDAP block — leave them.)
**Step 2 (test): Confirm 6 containers updated, 0 DevStub left:**
Run: `grep -c 'Security__Ldap__Server: "10.100.0.35"' /Users/dohertj2/Desktop/OtOpcUa/docker-dev/docker-compose.yml``6`; and `grep -c 'DevStubMode: "true"' …/docker-compose.yml``0`.
---
### Task 12: Apply seed + recreate `otopcua-dev` + browser-verify
**Classification:** high-risk
**Estimated implement time:** ~5 min (operational)
**Parallelizable with:** none
**Files:** none (operational)
**Step 1: Apply the new mapping rows to the running config DB** (host port 14330):
```bash
docker exec otopcua-dev-sql-1 /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'OtOpcUa!Dev123' -No -d OtOpcUa -Q "$(sed -n '/OtOpcUa-Admins/,/shared-glauth dev seed.);/p' /Users/dohertj2/Desktop/OtOpcUa/docker-dev/seed/seed-clusters.sql)"
```
(or simpler: re-run the seed container `docker compose -f docker-dev/docker-compose.yml up cluster-seed`). Verify: `… -Q "SELECT LdapGroup,Role FROM dbo.LdapGroupRoleMapping WHERE IsSystemWide=1"` → the 3 OtOpcUa-* rows.
**Step 2: Recreate the 6 admin/site host containers** (seed-first per cluster — recreate `admin-a` then `admin-b`; `site-a-1` then `site-a-2`; `site-b-1` then `site-b-2`):
```bash
cd /Users/dohertj2/Desktop/OtOpcUa/docker-dev
for n in admin-a admin-b site-a-1 site-a-2 site-b-1 site-b-2; do docker compose up -d --force-recreate --no-deps $n; sleep 3; done
```
**Step 3 (test): Browser** — log in `multi-role`/`password` at `http://localhost:9200/login`; expect the AdminUI Overview, SESSION panel showing `multi-role` + **Administrator** (from `OtOpcUa-Admins`→Administrator). Confirms the un-stub + real bind + DB mapping all work.
---
### Task 13: Commit OtOpcUa edits on a branch
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
**Files:** (commit) `docker-dev/docker-compose.yml`, `docker-dev/seed/seed-clusters.sql`
**Step 1:**
```bash
cd /Users/dohertj2/Desktop/OtOpcUa && git checkout -b feat/shared-glauth
git add docker-dev/docker-compose.yml docker-dev/seed/seed-clusters.sql
git commit -m "feat(auth): un-stub docker-dev onto shared GLAuth 10.100.0.35 + seed OtOpcUa-* role mappings"
```
(Do not merge/push.)
---
## Phase 3 — windev repoint + retire windev-local glauth (live host)
### Task 14: Repoint MxGateway (windev) at the shared GLAuth
**Classification:** high-risk
**Estimated implement time:** ~5 min (operational, live host)
**Parallelizable with:** Task 5, Task 10, Task 11
**Files:** (windev, deployment-only) `C:\publish\mxaccessgw\Server\appsettings.json` (`MxGateway:Ldap`)
**Step 1: Back up** `appsettings.json``appsettings.json.bak-20260604-glauth35` (skip if exists).
**Step 2: Edit `MxGateway:Ldap`** (literal replacements; preserve the rest, incl. the `Transport=None`/`AllowInsecure=true` migrated 2026-06-04, and `GroupToRole`):
- `"Server": "localhost"``"Server": "10.100.0.35"`
- `"SearchBase": "dc=lmxopcua,dc=local"``"SearchBase": "dc=zb,dc=local"`
- `"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local"``"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local"`
(`ServiceAccountPassword` stays `serviceaccount123`.) Use a `-File` PowerShell script (`[IO.File]::WriteAllText` after `.Replace(...)`), validate JSON parses.
**Step 3:** `Restart-Service MxAccessGw -Force; Start-Service OtOpcUa` (cascades to the dependent OtOpcUa svc — start it back).
**Step 4 (test):** From the Mac, `POST http://10.100.0.48:5130/auth/login` (GET `/login` for the antiforgery token+cookie first) with `username=multi-role&password=password``302 Location: /` (success). Browser-verify the dashboard logs in as `multi-role` (Administrator).
---
### Task 15: Repoint OtOpcUa (windev service) + switch transport to plaintext
**Classification:** high-risk
**Estimated implement time:** ~5 min (operational, live host)
**Parallelizable with:** Task 5, Task 10, Task 11
**Files:** (windev, deployment-only) `C:\publish\lmxopcua\appsettings.json` (`Security:Ldap`) — **discover any per-role overlay first** (`appsettings.admin.json`/`appsettings.driver.json` in `C:\publish\lmxopcua\` or `C:\publish\lmxopcua-admin\`; the live binary is `C:\publish\lmxopcua\OtOpcUa.Server.exe`).
**Step 1: Discovery**`Get-ChildItem C:\publish\lmxopcua\appsettings*.json` and inspect which file holds the live `Security:Ldap` (base + any `appsettings.admin.json` overlay that sets `Transport=Ldaps`). Back up whatever you edit.
**Step 2: Edit `Security:Ldap`** in the live config (and the admin overlay if present):
- `Server``10.100.0.35`; `SearchBase``dc=zb,dc=local`; `Transport` `Ldaps``None`; add/set `AllowInsecure` `true`; `ServiceAccountDn``cn=serviceaccount,dc=zb,dc=local`, `ServiceAccountPassword``serviceaccount123`; ensure `DevStubMode=false`.
**Step 3:** `Restart-Service OtOpcUa` (note the dependency direction: `MxAccessGw` depends on `OtOpcUa` — restarting OtOpcUa may require `-Force` and a follow-up `Start-Service MxAccessGw`; verify both Running).
**Step 4 (test):** Browser-verify the windev OtOpcUa AdminUI logs in as `multi-role` → Administrator. (Locate its dashboard URL during discovery.)
---
### Task 16: Stop/disable the windev-local glauth
**Classification:** small
**Estimated implement time:** ~2 min (operational)
**Parallelizable with:** none
**Files:** none (windev service)
**Step 1 (only after Tasks 14 + 15 verify green):** `Stop-Service glauth; Set-Service glauth -StartupType Manual` (disable autostart but keep installed for rollback). Keep `C:\publish\glauth\glauth.cfg` + the `glauth.cfg.bak-multirole-20260604` backup in place.
**Step 2 (test):** Re-run Task 14/15 logins once more to confirm windev auth still works with the local glauth down (proves they're truly on 35).
---
## Phase 4 — Final verification + housekeeping
### Task 17: Full cross-app verification matrix
**Classification:** high-risk
**Estimated implement time:** ~5 min (operational)
**Parallelizable with:** none
**Files:** none (operational)
**Step 1 (positive):** `multi-role`/`password` logs in on all five surfaces — ScadaBridge `:9000` + `:9100` (4 roles via `/auth/token`), OtOpcUa `:9200` (Administrator), MxGateway `10.100.0.48:5130` (Administrator), windev OtOpcUa.
**Step 2 (role-gating):** `gwreader`/`password` → MxGateway dashboard **Viewer-only** (no API-Keys/Settings admin pages); `designer`/`password` → ScadaBridge design nav but not ADMIN; `otviewer`/`password` → OtOpcUa read-only.
**Step 3 (negative):** wrong password rejected on every surface; a `SCADA-*`-only user (`designer`) gets **denied** on the MxGateway dashboard (no `Gw*` group). Record each result.
---
### Task 18: Update memory, design status, and finalize branches
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Update memory: `multi-role-cross-app-test-user.md` (now backed by the shared 35 GLAuth), `mxgateway-windev-deploy.md` + `scadabridge-local-deploy-gotchas.md` (repointed to 35), add a new `shared-glauth-on-35.md` (the directory layout, gid families, deploy/verify runbook, access caveat) + `MEMORY.md` index lines.
- Update: design doc status → "implemented".
- (Optional) align repo template appsettings (MxGateway/ScadaBridge) on the `feat/shared-glauth` branches so a clean redeploy doesn't reintroduce old keys.
**Step 1:** Write the memory updates. **Step 2:** Mark the design doc implemented. **Step 3:** Summarize branch state (scadaproj `docs/shared-glauth-standardization`; app `feat/shared-glauth` branches committed, not merged) and ask the user about merging.
---
## Execution notes
- **Phases 1, 2, 3 are independent** after Task 4 (different repos/hosts) — their first tasks (5, 10/11, 14/15) are mutually `Parallelizable`. Within a phase, recreate/verify tasks are sequential.
- Old glauths stay up until Tasks 6/16; every repoint is reversible by reverting the one-line `Server` change and recreating/restarting.
- Several tasks are **operational** (recreate clusters, live windev, the 35 deploy) — not code-with-unit-tests; their "tests" are the exact `ldapsearch`/`curl`/browser checks given.