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