# 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 25–32) - 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 ~44–50) **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.