From 43c5a015ca1e41119ad10a55f77703cbbb61a823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 28 May 2026 13:53:33 +0800 Subject: [PATCH] add fix eage case --- Makefile | 69 ++++--- deploy/README.md | 3 +- deploy/docker-compose.yml | 49 +---- deploy/openldap/README.md | 19 +- deploy/openldap/docker-compose.yml | 59 ++++++ deploy/zitadel/.gitignore | 4 + deploy/zitadel/README.md | 19 +- deploy/zitadel/bootstrap_dev.py | 315 +++++++++++++++++++++++++++++ deploy/zitadel/machinekey/k6.env | 4 - deploy/zitadel/zitadel.yaml | 2 +- etc/gateway.k6.yaml | 6 +- internal/library/zitadel/config.go | 6 +- 12 files changed, 452 insertions(+), 103 deletions(-) create mode 100644 deploy/openldap/docker-compose.yml create mode 100644 deploy/zitadel/bootstrap_dev.py delete mode 100644 deploy/zitadel/machinekey/k6.env diff --git a/Makefile b/Makefile index f8417c8..e2a9be4 100644 --- a/Makefile +++ b/Makefile @@ -83,13 +83,27 @@ deps-up-smtp: ## 起 Mongo + Redis + MailHog $(COMPOSE) --profile smtp up -d mongo redis mailhog deps-down: ## 停服務(保留 volume) - $(COMPOSE) --profile smtp --profile k6 down + $(COMPOSE) --profile smtp --profile k6 --profile ldap down deps-down-v: ## 停服務並刪 volume(會清資料) - $(COMPOSE) --profile smtp --profile k6 down -v + $(COMPOSE) --profile smtp --profile k6 --profile ldap down -v deps-logs: ## 看 compose log - $(COMPOSE) --profile smtp --profile k6 logs -f + $(COMPOSE) --profile smtp --profile k6 --profile ldap logs -f + +# OpenLDAP(本機 LDAP 測試,見 deploy/openldap/) +LDAP_COMPOSE := $(DOCKER_COMPOSE) -f deploy/openldap/docker-compose.yml + +ldap-up: ## 只起 OpenLDAP(profile ldap;ZITADEL 用 ldap://openldap:389) + $(COMPOSE) --profile ldap up -d openldap + @echo "→ run 'make ldap-wait' to seed alice/bob" + @echo "→ 測試帳號見 deploy/openldap/README.md" + +ldap-down: ## 停 OpenLDAP + $(COMPOSE) --profile ldap stop openldap + +ldap-wait: ## 等 OpenLDAP ready 並 seed alice/bob + @$(MAKE) -s k6-wait-ldap # ============================================================ # k6 測試(test/k6/) @@ -101,6 +115,7 @@ K6_GATEWAY_BIN := bin/gateway-k6 K6_DIR := test/k6 K6_PAT_FILE := deploy/zitadel/machinekey/zitadel-admin-sa.token K6_ENV_FILE := deploy/zitadel/machinekey/k6.env +K6_BOOTSTRAP_ENV := deploy/zitadel/machinekey/dev-bootstrap.env ZITADEL_HEALTH_URL := http://localhost:8080/debug/healthz # k6 安裝指引(macOS / Linux) @@ -154,7 +169,12 @@ ldap-test: ## 列出 LDAP 測試使用者 alice / bob -D "cn=admin,dc=gateway,dc=local" -w admin \ "(|(uid=alice)(uid=bob))" uid mail cn -k6-wait: k6-wait-ldap ## 等 OpenLDAP + ZITADEL ready + 把 PAT 寫到 k6.env +k6-bootstrap-zitadel: ## 自動建立 ZITADEL LDAP IdP + OIDC App(dev / k6) + @if [ ! -s "$(K6_PAT_FILE)" ]; then echo "PAT missing — run 'make k6-wait' first"; exit 1; fi + @python3 deploy/zitadel/bootstrap_dev.py + @echo "→ OAuth / LDAP IdP 寫入 $(K6_BOOTSTRAP_ENV)" + +k6-wait: k6-wait-ldap ## 等 OpenLDAP + ZITADEL ready + bootstrap IdP/OAuth + 寫 k6.env @echo "waiting for ZITADEL at $(ZITADEL_HEALTH_URL)…" @for i in $$(seq 1 120); do \ if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \ @@ -171,11 +191,14 @@ k6-wait: k6-wait-ldap ## 等 OpenLDAP + ZITADEL ready + 把 PAT 寫到 k6.env echo "PAT file $(K6_PAT_FILE) missing — check 'docker logs gateway-zitadel'"; \ exit 1; \ fi + @$(MAKE) -s k6-bootstrap-zitadel @PAT=$$(tr -d '\n' < $(K6_PAT_FILE)); \ printf 'export ZITADEL_SERVICE_TOKEN=%s\nexport BASE_URL=http://localhost:8888\nexport MAILHOG_URL=http://localhost:8025\nexport REDIS_ADDR=localhost:6379\n' "$$PAT" > $(K6_ENV_FILE); \ + if [ -s "$(K6_BOOTSTRAP_ENV)" ]; then cat $(K6_BOOTSTRAP_ENV) >> $(K6_ENV_FILE); fi; \ echo "wrote $(K6_ENV_FILE)" @$(MAKE) -s k6-seed-fixtures @echo "tip: 'source $(K6_ENV_FILE)' to load into your shell" + @echo "tip: run 'make dev-restart-gateway' if gateway was already running" # k6-seed-fixtures idempotently upserts the k6-tenant + K6INVITE invite code # into gateway_k6 so /auth/register resolves successfully. The invite hash is @@ -253,26 +276,7 @@ DEV_GATEWAY_LOG := $(DEV_DIR)/gateway.log .PHONY: dev-up dev-down dev-status dev-restart-gateway -dev-up: k6-up k6-wait k6-build ## 一鍵起全套:mongo/redis/mailhog/zitadel + seed + Gateway 背景 - @mkdir -p $(DEV_DIR) - @if [ -f $(DEV_GATEWAY_PID) ] && kill -0 $$(cat $(DEV_GATEWAY_PID)) 2>/dev/null; then \ - echo "gateway already running (pid $$(cat $(DEV_GATEWAY_PID)))"; \ - else \ - set -a; . $(K6_ENV_FILE); set +a; \ - nohup $(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG) > $(DEV_GATEWAY_LOG) 2>&1 & \ - echo $$! > $(DEV_GATEWAY_PID); \ - echo "gateway starting (pid $$(cat $(DEV_GATEWAY_PID)), log $(DEV_GATEWAY_LOG))…"; \ - for i in $$(seq 1 30); do \ - if curl -fsS http://localhost:8888/api/v1/health >/dev/null 2>&1; then \ - echo "gateway ready ($$i s)"; break; \ - fi; \ - sleep 1; \ - if [ $$i -eq 30 ]; then \ - echo "gateway did not become ready — tail $(DEV_GATEWAY_LOG)"; \ - tail -20 $(DEV_GATEWAY_LOG); exit 1; \ - fi; \ - done; \ - fi +dev-up: k6-up k6-wait k6-build dev-restart-gateway ## 一鍵起全套:mongo/redis/mailhog/zitadel + seed + Gateway 背景 @echo "" @echo "==========================================" @echo " 本機測試環境已就緒" @@ -283,11 +287,12 @@ dev-up: k6-up k6-wait k6-build ## 一鍵起全套:mongo/redis/mailhog/zitadel @echo " ZITADEL 主控台 http://localhost:8080/ui/console" @echo "" @echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE" - @echo " Email 可填任意地址(例:you@test.com),OTP 不會進真實信箱" + @echo " LDAP 登入:alice / Password1!(make k6-wait 已自動設定 ZITADEL IdP)" @echo "" @echo " 管理後台:make dev-seed-admin 後用輸出的帳密登入" @echo " 關閉環境:make dev-down" @echo " 查看狀態:make dev-status" + @echo " OAuth/LDAP 設定變更後:make dev-restart-gateway" @echo "==========================================" dev-seed-admin: k6-seed-admin ## 建立具 tenant_admin 的管理員(dev-up 之後執行) @@ -315,17 +320,29 @@ dev-status: ## 顯示 docker / gateway / health 狀態 echo "not running (run: make dev-up)"; \ fi -dev-restart-gateway: k6-build ## 只重啟 Gateway(docker 不動) +dev-restart-gateway: k6-build ## 重啟 Gateway(載入 k6.env 的 OAuth / LDAP 設定) + @if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi @if [ -f $(DEV_GATEWAY_PID) ]; then \ pid=$$(cat $(DEV_GATEWAY_PID)); \ kill -0 $$pid 2>/dev/null && kill $$pid || true; \ rm -f $(DEV_GATEWAY_PID); \ fi + @if command -v lsof >/dev/null 2>&1; then \ + orphan=$$(lsof -ti :8888 2>/dev/null || true); \ + if [ -n "$$orphan" ]; then kill $$orphan 2>/dev/null || true; sleep 1; fi; \ + fi @mkdir -p $(DEV_DIR) @set -a; . $(K6_ENV_FILE); set +a; \ nohup $(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG) > $(DEV_GATEWAY_LOG) 2>&1 & \ echo $$! > $(DEV_GATEWAY_PID); \ echo "gateway restarted (pid $$(cat $(DEV_GATEWAY_PID)))" + @for i in $$(seq 1 30); do \ + if curl -fsS http://localhost:8888/api/v1/health >/dev/null 2>&1; then \ + echo "gateway ready ($$i s)"; exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "gateway did not become ready — tail $(DEV_GATEWAY_LOG)"; tail -20 $(DEV_GATEWAY_LOG); exit 1 # ============================================================ # Frontend(使用者前台 + 管理後台) diff --git a/deploy/README.md b/deploy/README.md index 550bd42..e0f0a17 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -7,7 +7,7 @@ Gateway 啟用 **Notification** / **Member OTP** 需要: | **MongoDB** | `notifications`、`notification_dlq` collections | 27017 | | **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 | | MailHog(選用) | 本機 SMTP 測試 | 1025 / 8025 | -| OpenLDAP(`make k6-up`) | ZITADEL LDAP IdP 本機目錄 | 389 | +| OpenLDAP(`make ldap-up` / `make k6-up`) | ZITADEL LDAP IdP 本機目錄 | 389 | | ZITADEL(`make k6-up`) | OIDC / Social / LDAP 登入 | 8080 | Mongo **不需要**事先手動建 collection;應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。 @@ -42,6 +42,7 @@ make run-dev ```bash make deps-up # docker compose up -d mongo redis make deps-up-smtp # 再加上 mailhog(profile smtp) +make ldap-up # 只起 OpenLDAP(profile ldap) make k6-up # 全棧含 OpenLDAP + ZITADEL(見 deploy/zitadel、deploy/openldap README) make ldap-test # 確認 LDAP 測試帳號 alice/bob make deps-down # 停止並移除容器(保留 volume) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a62b108..4c204ad 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -3,10 +3,14 @@ # 啟動: # make deps-up → mongo + redis(最小,吃 etc/gateway.dev.yaml) # make deps-up-smtp → + mailhog(profile smtp) -# make k6-up → mongo + redis + mailhog + postgres + zitadel(吃 etc/gateway.k6.yaml) +# make ldap-up → 只起 OpenLDAP(profile ldap,見 deploy/openldap/) +# make k6-up → mongo + redis + mailhog + postgres + openldap + zitadel # # ZITADEL admin PAT 會寫到 deploy/zitadel/machinekey/zitadel-admin-sa.token +include: + - path: openldap/docker-compose.yml + services: mongo: image: mongo:7 @@ -70,47 +74,6 @@ services: timeout: 3s retries: 20 - openldap: - profiles: ["k6"] - image: osixia/openldap:1.5.0 - container_name: gateway-openldap - restart: unless-stopped - environment: - LDAP_ORGANISATION: "GatewayDev" - LDAP_DOMAIN: "gateway.local" - LDAP_ADMIN_PASSWORD: "admin" - LDAP_CONFIG_PASSWORD: "config" - LDAP_TLS: "false" - ports: - - "389:389" - volumes: - - openldap_data:/var/lib/ldap - - openldap_config:/etc/ldap/slapd.d - healthcheck: - test: - [ - "CMD", - "ldapsearch", - "-x", - "-H", - "ldap://localhost", - "-b", - "dc=gateway,dc=local", - "-D", - "cn=admin,dc=gateway,dc=local", - "-w", - "admin", - "-LLL", - "-s", - "base", - "(objectClass=*)", - "dn", - ] - interval: 5s - timeout: 5s - retries: 24 - start_period: 20s - zitadel: profiles: ["k6"] image: ghcr.io/zitadel/zitadel:v2.65.0 @@ -141,5 +104,3 @@ volumes: mongo_data: redis_data: postgres_data: - openldap_data: - openldap_config: diff --git a/deploy/openldap/README.md b/deploy/openldap/README.md index f624723..a9df42e 100644 --- a/deploy/openldap/README.md +++ b/deploy/openldap/README.md @@ -1,16 +1,27 @@ # 本機 OpenLDAP(k6 / dev) -與 `make k6-up` 一併啟動的測試用 LDAP 目錄。ZITADEL 透過 **LDAP IdP** 連到這台伺服器;Gateway 仍只走 OIDC(`provider=ldap` + `LdapIdPID`)。 +與 `make k6-up` 或 `make ldap-up` 一併啟動的測試用 LDAP 目錄。ZITADEL 透過 **LDAP IdP** 連到這台伺服器;Gateway 仍只走 OIDC(`provider=ldap` + `LdapIdPID`)。 + +Compose 定義在 [docker-compose.yml](docker-compose.yml),由主 [deploy/docker-compose.yml](../docker-compose.yml) `include` 進來。 ## 啟動 ```bash -make k6-up # 含 openldap -make k6-wait # 等 OpenLDAP seed + ZITADEL ready +# 方式 A:只起 LDAP(已有 ZITADEL 或其他服務時) +make ldap-up +make ldap-wait + +# 方式 B:k6 全棧(含 Postgres + ZITADEL + OpenLDAP) +make k6-up +make k6-wait # 自動:LDAP seed + ZITADEL LDAP IdP + OIDC App → k6.env + +# 一鍵(含 Gateway 背景) +make dev-up + make ldap-test # 確認 alice / bob 可查 ``` -`make k6-wait` 會自動執行 `ldap-seed`,把 [bootstrap/10-people.ldif](bootstrap/10-people.ldif) 寫入目錄(可重複執行)。 +`make k6-wait` 會執行 `ldap-seed`,並跑 [`deploy/zitadel/bootstrap_dev.py`](../zitadel/bootstrap_dev.py) 自動建立 ZITADEL **LDAP IdP** 與 **OIDC Web App**,把 `ZITADEL_LDAP_IDP_ID` / `ZITADEL_OAUTH_CLIENT_*` 寫入 `deploy/zitadel/machinekey/k6.env`。**不必再手動開 Console。** ## 連線資訊 diff --git a/deploy/openldap/docker-compose.yml b/deploy/openldap/docker-compose.yml new file mode 100644 index 0000000..a911cf5 --- /dev/null +++ b/deploy/openldap/docker-compose.yml @@ -0,0 +1,59 @@ +# 本機 OpenLDAP(LDAP + ZITADEL 整合測試) +# +# 獨立啟動(只跑 LDAP): +# docker compose -f deploy/openldap/docker-compose.yml up -d +# make ldap-wait # 等 ready 並 seed alice/bob +# +# 或由主 compose 以 profile 啟動: +# make ldap-up # profile ldap +# make k6-up # profile k6(含 ZITADEL + Postgres + OpenLDAP) +# +# ZITADEL Console 建 LDAP IdP 時,Server 填 ldap://openldap:389(容器網路) +# 本機 ldapsearch 用 localhost:389 + +services: + openldap: + profiles: ["ldap", "k6"] + image: osixia/openldap:1.5.0 + container_name: gateway-openldap + restart: unless-stopped + environment: + LDAP_ORGANISATION: "GatewayDev" + LDAP_DOMAIN: "gateway.local" + LDAP_ADMIN_PASSWORD: "admin" + LDAP_CONFIG_PASSWORD: "config" + LDAP_TLS: "false" + ports: + - "389:389" + volumes: + - openldap_data:/var/lib/ldap + - openldap_config:/etc/ldap/slapd.d + # 測試帳號由 make ldap-seed 寫入(勿 :ro 掛 bootstrap,osixia 啟動需 chown 會失敗) + healthcheck: + test: + [ + "CMD", + "ldapsearch", + "-x", + "-H", + "ldap://localhost", + "-b", + "dc=gateway,dc=local", + "-D", + "cn=admin,dc=gateway,dc=local", + "-w", + "admin", + "-LLL", + "-s", + "base", + "(objectClass=*)", + "dn", + ] + interval: 5s + timeout: 5s + retries: 24 + start_period: 20s + +volumes: + openldap_data: + openldap_config: diff --git a/deploy/zitadel/.gitignore b/deploy/zitadel/.gitignore index f9eecbe..8867352 100644 --- a/deploy/zitadel/.gitignore +++ b/deploy/zitadel/.gitignore @@ -1,3 +1,7 @@ # ZITADEL bootstrap outputs(PAT / machine key)— 不入 git machinekey/zitadel-admin-sa.token machinekey/zitadel-admin-sa.json +machinekey/dev-bootstrap.env +machinekey/k6.env +machinekey/k6.env.admin +machinekey/k6.env.tmp diff --git a/deploy/zitadel/README.md b/deploy/zitadel/README.md index 36a0e71..ba7f1d0 100644 --- a/deploy/zitadel/README.md +++ b/deploy/zitadel/README.md @@ -88,24 +88,9 @@ Zitadel: ### 3. 設定 LDAP IdP(本機 OpenLDAP) -`make k6-up` 已含測試目錄,帳號與 ZITADEL 欄位對照見 **[deploy/openldap/README.md](../openldap/README.md)**。 +`make k6-wait` 會**自動**建立 LDAP IdP 與 OIDC App([`bootstrap_dev.py`](bootstrap_dev.py)),並把 IdP ID 寫入 `machinekey/k6.env` 的 `ZITADEL_LDAP_IDP_ID`。 -摘要(在 ZITADEL Console 建 Generic LDAP IdP): - -| 欄位 | 值 | -|------|-----| -| Server | `ldap://openldap:389` | -| Bind DN | `cn=admin,dc=gateway,dc=local` | -| Bind password | `admin` | -| User base | `ou=people,dc=gateway,dc=local` | -| Login | uid=`alice`,密碼=`Password1!` | - -建立後複製 **IdP ID** → `Zitadel.LdapIdPID`,並確認已設定 OIDC App(§1)。 - -```yaml -Zitadel: - LdapIdPID: "" -``` +手動設定(選用)見 **[deploy/openldap/README.md](../openldap/README.md)**。 驗證目錄:`make ldap-test` diff --git a/deploy/zitadel/bootstrap_dev.py b/deploy/zitadel/bootstrap_dev.py new file mode 100644 index 0000000..136df16 --- /dev/null +++ b/deploy/zitadel/bootstrap_dev.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Idempotent dev bootstrap: OpenLDAP IdP + OIDC Web app for Gateway (k6 / dev-up). + +Writes deploy/zitadel/machinekey/dev-bootstrap.env with: + ZITADEL_DEFAULT_ORG_ID, ZITADEL_OAUTH_CLIENT_ID, ZITADEL_OAUTH_CLIENT_SECRET, + ZITADEL_LDAP_IDP_ID, ZITADEL_GOOGLE_IDP_ID (empty) + +Requires: ZITADEL up, PAT at deploy/zitadel/machinekey/zitadel-admin-sa.token +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PAT_FILE = ROOT / "deploy/zitadel/machinekey/zitadel-admin-sa.token" +OUT_FILE = ROOT / "deploy/zitadel/machinekey/dev-bootstrap.env" + +ZITADEL_BASE = os.environ.get("ZITADEL_BASE", "http://localhost:8080").rstrip("/") + +LDAP_IDP_NAME = "GatewayDevLDAP" +PROJECT_NAME = "Gateway" +APP_NAME = "Gateway Backend" +REDIRECT_URIS = [ + "http://localhost:5173/auth/callback/login", + "http://localhost:5173/auth/callback/register", +] +POST_LOGOUT_URIS = ["http://localhost:5173/"] + +LDAP_BODY = { + "name": LDAP_IDP_NAME, + "servers": ["ldap://openldap:389"], + "startTls": False, + "baseDn": "dc=gateway,dc=local", + "bindDn": "cn=admin,dc=gateway,dc=local", + "bindPassword": "admin", + "userBase": "ou=people,dc=gateway,dc=local", + "userObjectClasses": ["inetOrgPerson"], + "userFilters": ["(uid=%s)"], + "attributes": { + "idAttribute": "uid", + "emailAttribute": "mail", + "firstNameAttribute": "givenName", + "lastNameAttribute": "sn", + "displayNameAttribute": "cn", + "nickNameAttribute": "uid", + }, + "creationAllowed": True, + "linkingAllowed": True, + "autoCreation": True, + "autoUpdate": True, +} + + +class BootstrapError(RuntimeError): + pass + + +def log(msg: str) -> None: + print(f"[zitadel-bootstrap] {msg}", file=sys.stderr) + + +def api(method: str, path: str, body: dict | None = None) -> dict: + url = f"{ZITADEL_BASE}{path}" + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Authorization": f"Bearer {read_pat()}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode() + if not raw.strip(): + return {} + return json.loads(raw) + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + raise BootstrapError(f"{method} {path} -> HTTP {e.code}: {detail}") from e + + +def read_pat() -> str: + if not PAT_FILE.is_file(): + raise BootstrapError(f"PAT missing: {PAT_FILE} (run make k6-wait)") + pat = PAT_FILE.read_text().strip() + if not pat: + raise BootstrapError(f"PAT empty: {PAT_FILE}") + return pat + + +def load_saved() -> dict[str, str]: + if not OUT_FILE.is_file(): + return {} + out: dict[str, str] = {} + for line in OUT_FILE.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + if k.startswith("export "): + k = k[len("export ") :] + out[k.strip()] = v.strip().strip('"').strip("'") + return out + + +def write_env(org_id: str, client_id: str, client_secret: str, ldap_idp_id: str) -> None: + OUT_FILE.parent.mkdir(parents=True, exist_ok=True) + content = f"""# Auto-generated by deploy/zitadel/bootstrap_dev.py — do not commit +export ZITADEL_DEFAULT_ORG_ID={org_id} +export ZITADEL_OAUTH_CLIENT_ID={client_id} +export ZITADEL_OAUTH_CLIENT_SECRET={client_secret} +export ZITADEL_LDAP_IDP_ID={ldap_idp_id} +export ZITADEL_GOOGLE_IDP_ID= +""" + OUT_FILE.write_text(content) + log(f"wrote {OUT_FILE.relative_to(ROOT)}") + + +def org_id() -> str: + data = api("GET", "/management/v1/orgs/me") + oid = (data.get("org") or {}).get("id") or "" + if not oid: + raise BootstrapError("could not resolve org id") + return oid + + +def login_policy() -> tuple[dict, bool]: + data = api("GET", "/management/v1/policies/login") + return data.get("policy") or {}, bool(data.get("isDefault")) + + +def find_ldap_idp_in_policy(policy: dict) -> str: + for item in policy.get("idps") or []: + if item.get("idpName") == LDAP_IDP_NAME: + return item.get("idpId") or "" + return "" + + +def ensure_ldap_idp() -> str: + policy, is_default = login_policy() + existing = find_ldap_idp_in_policy(policy) + if existing: + log(f"LDAP IdP already linked: {existing}") + return existing + + created = api("POST", "/management/v1/idps/ldap", LDAP_BODY) + idp_id = created.get("id") or "" + if not idp_id: + raise BootstrapError(f"create LDAP IdP: unexpected response {created}") + + log(f"created LDAP IdP {idp_id}") + + if is_default or not policy.get("allowExternalIdp"): + api( + "POST", + "/management/v1/policies/login", + { + "allowExternalIdp": True, + "allowUsernamePassword": True, + "allowRegister": True, + "passwordlessType": "PASSWORDLESS_TYPE_ALLOWED", + "idps": [{"idpId": idp_id, "ownerType": "IDP_OWNER_TYPE_ORG"}], + }, + ) + log("created org login policy with LDAP IdP") + else: + api("POST", "/management/v1/policies/login/idps", {"idpId": idp_id}) + log("linked LDAP IdP to existing login policy") + + return idp_id + + +def find_project() -> str: + data = api( + "POST", + "/management/v1/projects/_search", + { + "queries": [ + { + "nameQuery": { + "name": PROJECT_NAME, + "method": "TEXT_QUERY_METHOD_EQUALS", + } + } + ] + }, + ) + for item in data.get("result") or []: + if item.get("name") == PROJECT_NAME: + return item.get("id") or "" + created = api( + "POST", + "/management/v1/projects", + { + "name": PROJECT_NAME, + "projectRoleAssertion": True, + "projectRoleCheck": False, + "hasProjectCheck": False, + "privateLabelingSetting": "PRIVATE_LABELING_SETTING_UNSPECIFIED", + }, + ) + pid = created.get("id") or "" + if not pid: + raise BootstrapError(f"create project: unexpected response {created}") + log(f"created project {PROJECT_NAME} ({pid})") + return pid + + +def find_app(project_id: str) -> tuple[str, str]: + data = api( + "POST", + f"/management/v1/projects/{project_id}/apps/_search", + { + "queries": [ + { + "nameQuery": { + "name": APP_NAME, + "method": "TEXT_QUERY_METHOD_EQUALS", + } + } + ] + }, + ) + for item in data.get("result") or []: + if item.get("name") != APP_NAME: + continue + app_id = item.get("id") or "" + client_id = (item.get("oidcConfig") or {}).get("clientId") or "" + return app_id, client_id + return "", "" + + +def create_app(project_id: str) -> tuple[str, str, str]: + created = api( + "POST", + f"/management/v1/projects/{project_id}/apps/oidc", + { + "name": APP_NAME, + "redirectUris": REDIRECT_URIS, + "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], + "grantTypes": [ + "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", + "OIDC_GRANT_TYPE_REFRESH_TOKEN", + ], + "appType": "OIDC_APP_TYPE_WEB", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC", + "postLogoutRedirectUris": POST_LOGOUT_URIS, + "devMode": True, + "accessTokenType": "OIDC_TOKEN_TYPE_BEARER", + }, + ) + app_id = created.get("appId") or "" + client_id = created.get("clientId") or "" + client_secret = created.get("clientSecret") or "" + if not app_id or not client_id or not client_secret: + raise BootstrapError(f"create OIDC app: unexpected response {created}") + log(f"created OIDC app {APP_NAME} client_id={client_id}") + return app_id, client_id, client_secret + + +def regenerate_secret(project_id: str, app_id: str) -> str: + data = api( + "POST", + f"/management/v1/projects/{project_id}/apps/{app_id}/oidc_config/_generate_client_secret", + {}, + ) + secret = data.get("clientSecret") or "" + if not secret: + raise BootstrapError(f"regenerate client secret: unexpected response {data}") + log("regenerated OIDC client secret") + return secret + + +def ensure_oidc_app(saved: dict[str, str]) -> tuple[str, str]: + project_id = find_project() + app_id, client_id = find_app(project_id) + if not app_id: + app_id, client_id, client_secret = create_app(project_id) + return client_id, client_secret + + log(f"OIDC app exists client_id={client_id}") + saved_id = saved.get("ZITADEL_OAUTH_CLIENT_ID", "") + saved_secret = saved.get("ZITADEL_OAUTH_CLIENT_SECRET", "") + if saved_id == client_id and saved_secret: + return client_id, saved_secret + + return client_id, regenerate_secret(project_id, app_id) + + +def main() -> int: + try: + oid = org_id() + ldap_idp_id = ensure_ldap_idp() + saved = load_saved() + client_id, client_secret = ensure_oidc_app(saved) + write_env(oid, client_id, client_secret, ldap_idp_id) + print(f"LDAP IdP={ldap_idp_id} OAuth client={client_id}") + return 0 + except BootstrapError as e: + log(f"error: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/zitadel/machinekey/k6.env b/deploy/zitadel/machinekey/k6.env deleted file mode 100644 index 4981a83..0000000 --- a/deploy/zitadel/machinekey/k6.env +++ /dev/null @@ -1,4 +0,0 @@ -export ZITADEL_SERVICE_TOKEN=j2uy--q988R_b4kaB0-kCsWe-NpzpejaiV66AYT5g1pRvLSJff4Mke833P0XkTJtmWt6iLM -export BASE_URL=http://localhost:8888 -export MAILHOG_URL=http://localhost:8025 -export REDIS_ADDR=localhost:6379 diff --git a/deploy/zitadel/zitadel.yaml b/deploy/zitadel/zitadel.yaml index 79e4e12..25c76a1 100644 --- a/deploy/zitadel/zitadel.yaml +++ b/deploy/zitadel/zitadel.yaml @@ -35,7 +35,7 @@ DefaultInstance: LoginPolicy: AllowRegister: true AllowUsernamePassword: true - AllowExternalIDP: false + AllowExternalIDP: true ForceMFA: false HidePasswordReset: false PasswordlessType: 1 diff --git a/etc/gateway.k6.yaml b/etc/gateway.k6.yaml index f54e8de..530a242 100644 --- a/etc/gateway.k6.yaml +++ b/etc/gateway.k6.yaml @@ -107,11 +107,11 @@ Zitadel: APIBase: http://localhost:8080 ServiceUserToken: "" DefaultOrgID: "" - OAuthClientID: "" - OAuthClientSecret: "" + OAuthClientID: "374875801008562439" + OAuthClientSecret: "Z1SUCIsozer52x2DNhKHXcTZROicf2lFFLLr4pkTZjLXHfkunTzjKYmMk2EHKDch" GoogleClientID: "" GoogleClientSecret: "" GoogleIdPID: "" - LdapIdPID: "" + LdapIdPID: "374875427463782663" JWKSUrl: "" TimeoutSeconds: 15 diff --git a/internal/library/zitadel/config.go b/internal/library/zitadel/config.go index 0e9eeb8..067018b 100644 --- a/internal/library/zitadel/config.go +++ b/internal/library/zitadel/config.go @@ -11,9 +11,9 @@ type Conf struct { // ServiceUserToken is a PAT or service-account token for Management API (CreateUser, Deactivate). ServiceUserToken string `json:",optional,env=ZITADEL_SERVICE_TOKEN"` // DefaultOrgID is used when CreateHumanUserRequest.OrgID is empty. - DefaultOrgID string `json:",optional"` + DefaultOrgID string `json:",optional,env=ZITADEL_DEFAULT_ORG_ID"` // OAuthClientID and OAuthClientSecret identify the Gateway OIDC application (password grant / social). - OAuthClientID string `json:",optional"` + OAuthClientID string `json:",optional,env=ZITADEL_OAUTH_CLIENT_ID"` OAuthClientSecret string `json:",optional,env=ZITADEL_OAUTH_CLIENT_SECRET"` // Google OAuth app credentials (register/social flow, PR 6). GoogleClientID string `json:",optional"` @@ -21,7 +21,7 @@ type Conf struct { // GoogleIdPID is the ZITADEL external IdP id for Google (optional idp_id authorize hint). GoogleIdPID string `json:",optional"` // LdapIdPID is the ZITADEL external IdP id for LDAP (optional idp_id authorize hint). - LdapIdPID string `json:",optional"` + LdapIdPID string `json:",optional,env=ZITADEL_LDAP_IDP_ID"` // JWKSUrl overrides OIDC JWKS endpoint; defaults to {Issuer}/oauth/v2/keys. JWKSUrl string `json:",optional"` TimeoutSeconds int `json:",optional"`