add fix eage case

This commit is contained in:
王性驊 2026-05-28 13:53:33 +08:00
parent 9dd8287777
commit 43c5a015ca
12 changed files with 452 additions and 103 deletions

View File

@ -83,13 +83,27 @@ deps-up-smtp: ## 起 Mongo + Redis + MailHog
$(COMPOSE) --profile smtp up -d mongo redis mailhog $(COMPOSE) --profile smtp up -d mongo redis mailhog
deps-down: ## 停服務(保留 volume deps-down: ## 停服務(保留 volume
$(COMPOSE) --profile smtp --profile k6 down $(COMPOSE) --profile smtp --profile k6 --profile ldap down
deps-down-v: ## 停服務並刪 volume會清資料 deps-down-v: ## 停服務並刪 volume會清資料
$(COMPOSE) --profile smtp --profile k6 down -v $(COMPOSE) --profile smtp --profile k6 --profile ldap down -v
deps-logs: ## 看 compose log 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: ## 只起 OpenLDAPprofile ldapZITADEL 用 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/ # k6 測試test/k6/
@ -101,6 +115,7 @@ K6_GATEWAY_BIN := bin/gateway-k6
K6_DIR := test/k6 K6_DIR := test/k6
K6_PAT_FILE := deploy/zitadel/machinekey/zitadel-admin-sa.token K6_PAT_FILE := deploy/zitadel/machinekey/zitadel-admin-sa.token
K6_ENV_FILE := deploy/zitadel/machinekey/k6.env 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 ZITADEL_HEALTH_URL := http://localhost:8080/debug/healthz
# k6 安裝指引macOS / Linux # k6 安裝指引macOS / Linux
@ -154,7 +169,12 @@ ldap-test: ## 列出 LDAP 測試使用者 alice / bob
-D "cn=admin,dc=gateway,dc=local" -w admin \ -D "cn=admin,dc=gateway,dc=local" -w admin \
"(|(uid=alice)(uid=bob))" uid mail cn "(|(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 Appdev / 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)" @echo "waiting for ZITADEL at $(ZITADEL_HEALTH_URL)"
@for i in $$(seq 1 120); do \ @for i in $$(seq 1 120); do \
if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \ 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'"; \ echo "PAT file $(K6_PAT_FILE) missing — check 'docker logs gateway-zitadel'"; \
exit 1; \ exit 1; \
fi fi
@$(MAKE) -s k6-bootstrap-zitadel
@PAT=$$(tr -d '\n' < $(K6_PAT_FILE)); \ @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); \ 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)" echo "wrote $(K6_ENV_FILE)"
@$(MAKE) -s k6-seed-fixtures @$(MAKE) -s k6-seed-fixtures
@echo "tip: 'source $(K6_ENV_FILE)' to load into your shell" @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 # k6-seed-fixtures idempotently upserts the k6-tenant + K6INVITE invite code
# into gateway_k6 so /auth/register resolves successfully. The invite hash is # 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 .PHONY: dev-up dev-down dev-status dev-restart-gateway
dev-up: k6-up k6-wait k6-build ## 一鍵起全套mongo/redis/mailhog/zitadel + seed + Gateway 背景 dev-up: k6-up k6-wait k6-build dev-restart-gateway ## 一鍵起全套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
@echo "" @echo ""
@echo "==========================================" @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 " ZITADEL 主控台 http://localhost:8080/ui/console"
@echo "" @echo ""
@echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE" @echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE"
@echo " Email 可填任意地址you@test.comOTP 不會進真實信箱" @echo " LDAP 登入alice / Password1!make k6-wait 已自動設定 ZITADEL IdP"
@echo "" @echo ""
@echo " 管理後台make dev-seed-admin 後用輸出的帳密登入" @echo " 管理後台make dev-seed-admin 後用輸出的帳密登入"
@echo " 關閉環境make dev-down" @echo " 關閉環境make dev-down"
@echo " 查看狀態make dev-status" @echo " 查看狀態make dev-status"
@echo " OAuth/LDAP 設定變更後make dev-restart-gateway"
@echo "==========================================" @echo "=========================================="
dev-seed-admin: k6-seed-admin ## 建立具 tenant_admin 的管理員dev-up 之後執行) 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)"; \ echo "not running (run: make dev-up)"; \
fi fi
dev-restart-gateway: k6-build ## 只重啟 Gatewaydocker 不動) 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 \ @if [ -f $(DEV_GATEWAY_PID) ]; then \
pid=$$(cat $(DEV_GATEWAY_PID)); \ pid=$$(cat $(DEV_GATEWAY_PID)); \
kill -0 $$pid 2>/dev/null && kill $$pid || true; \ kill -0 $$pid 2>/dev/null && kill $$pid || true; \
rm -f $(DEV_GATEWAY_PID); \ rm -f $(DEV_GATEWAY_PID); \
fi 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) @mkdir -p $(DEV_DIR)
@set -a; . $(K6_ENV_FILE); set +a; \ @set -a; . $(K6_ENV_FILE); set +a; \
nohup $(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG) > $(DEV_GATEWAY_LOG) 2>&1 & \ nohup $(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG) > $(DEV_GATEWAY_LOG) 2>&1 & \
echo $$! > $(DEV_GATEWAY_PID); \ echo $$! > $(DEV_GATEWAY_PID); \
echo "gateway restarted (pid $$(cat $(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使用者前台 + 管理後台) # Frontend使用者前台 + 管理後台)

View File

@ -7,7 +7,7 @@ Gateway 啟用 **Notification** / **Member OTP** 需要:
| **MongoDB** | `notifications`、`notification_dlq` collections | 27017 | | **MongoDB** | `notifications`、`notification_dlq` collections | 27017 |
| **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 | | **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 |
| MailHog選用 | 本機 SMTP 測試 | 1025 / 8025 | | 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 | | ZITADEL`make k6-up` | OIDC / Social / LDAP 登入 | 8080 |
Mongo **不需要**事先手動建 collection應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。 Mongo **不需要**事先手動建 collection應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。
@ -42,6 +42,7 @@ make run-dev
```bash ```bash
make deps-up # docker compose up -d mongo redis make deps-up # docker compose up -d mongo redis
make deps-up-smtp # 再加上 mailhogprofile smtp make deps-up-smtp # 再加上 mailhogprofile smtp
make ldap-up # 只起 OpenLDAPprofile ldap
make k6-up # 全棧含 OpenLDAP + ZITADEL見 deploy/zitadel、deploy/openldap README make k6-up # 全棧含 OpenLDAP + ZITADEL見 deploy/zitadel、deploy/openldap README
make ldap-test # 確認 LDAP 測試帳號 alice/bob make ldap-test # 確認 LDAP 測試帳號 alice/bob
make deps-down # 停止並移除容器(保留 volume make deps-down # 停止並移除容器(保留 volume

View File

@ -3,10 +3,14 @@
# 啟動: # 啟動:
# make deps-up → mongo + redis最小吃 etc/gateway.dev.yaml # make deps-up → mongo + redis最小吃 etc/gateway.dev.yaml
# make deps-up-smtp → + mailhogprofile smtp # make deps-up-smtp → + mailhogprofile smtp
# make k6-up → mongo + redis + mailhog + postgres + zitadel吃 etc/gateway.k6.yaml # make ldap-up → 只起 OpenLDAPprofile ldap見 deploy/openldap/
# make k6-up → mongo + redis + mailhog + postgres + openldap + zitadel
# #
# ZITADEL admin PAT 會寫到 deploy/zitadel/machinekey/zitadel-admin-sa.token # ZITADEL admin PAT 會寫到 deploy/zitadel/machinekey/zitadel-admin-sa.token
include:
- path: openldap/docker-compose.yml
services: services:
mongo: mongo:
image: mongo:7 image: mongo:7
@ -70,47 +74,6 @@ services:
timeout: 3s timeout: 3s
retries: 20 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: zitadel:
profiles: ["k6"] profiles: ["k6"]
image: ghcr.io/zitadel/zitadel:v2.65.0 image: ghcr.io/zitadel/zitadel:v2.65.0
@ -141,5 +104,3 @@ volumes:
mongo_data: mongo_data:
redis_data: redis_data:
postgres_data: postgres_data:
openldap_data:
openldap_config:

View File

@ -1,16 +1,27 @@
# 本機 OpenLDAPk6 / dev # 本機 OpenLDAPk6 / 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 ```bash
make k6-up # 含 openldap # 方式 A只起 LDAP已有 ZITADEL 或其他服務時)
make k6-wait # 等 OpenLDAP seed + ZITADEL ready make ldap-up
make ldap-wait
# 方式 Bk6 全棧(含 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 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。**
## 連線資訊 ## 連線資訊

View File

@ -0,0 +1,59 @@
# 本機 OpenLDAPLDAP + 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 掛 bootstraposixia 啟動需 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:

View File

@ -1,3 +1,7 @@
# ZITADEL bootstrap outputsPAT / machine key— 不入 git # ZITADEL bootstrap outputsPAT / machine key— 不入 git
machinekey/zitadel-admin-sa.token machinekey/zitadel-admin-sa.token
machinekey/zitadel-admin-sa.json machinekey/zitadel-admin-sa.json
machinekey/dev-bootstrap.env
machinekey/k6.env
machinekey/k6.env.admin
machinekey/k6.env.tmp

View File

@ -88,24 +88,9 @@ Zitadel:
### 3. 設定 LDAP IdP本機 OpenLDAP ### 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 手動設定(選用)見 **[deploy/openldap/README.md](../openldap/README.md)**。
| 欄位 | 值 |
|------|-----|
| 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: "<zitadel-ldap-idp-id>"
```
驗證目錄:`make ldap-test` 驗證目錄:`make ldap-test`

View File

@ -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())

View File

@ -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

View File

@ -35,7 +35,7 @@ DefaultInstance:
LoginPolicy: LoginPolicy:
AllowRegister: true AllowRegister: true
AllowUsernamePassword: true AllowUsernamePassword: true
AllowExternalIDP: false AllowExternalIDP: true
ForceMFA: false ForceMFA: false
HidePasswordReset: false HidePasswordReset: false
PasswordlessType: 1 PasswordlessType: 1

View File

@ -107,11 +107,11 @@ Zitadel:
APIBase: http://localhost:8080 APIBase: http://localhost:8080
ServiceUserToken: "" ServiceUserToken: ""
DefaultOrgID: "" DefaultOrgID: ""
OAuthClientID: "" OAuthClientID: "374875801008562439"
OAuthClientSecret: "" OAuthClientSecret: "Z1SUCIsozer52x2DNhKHXcTZROicf2lFFLLr4pkTZjLXHfkunTzjKYmMk2EHKDch"
GoogleClientID: "" GoogleClientID: ""
GoogleClientSecret: "" GoogleClientSecret: ""
GoogleIdPID: "" GoogleIdPID: ""
LdapIdPID: "" LdapIdPID: "374875427463782663"
JWKSUrl: "" JWKSUrl: ""
TimeoutSeconds: 15 TimeoutSeconds: 15

View File

@ -11,9 +11,9 @@ type Conf struct {
// ServiceUserToken is a PAT or service-account token for Management API (CreateUser, Deactivate). // ServiceUserToken is a PAT or service-account token for Management API (CreateUser, Deactivate).
ServiceUserToken string `json:",optional,env=ZITADEL_SERVICE_TOKEN"` ServiceUserToken string `json:",optional,env=ZITADEL_SERVICE_TOKEN"`
// DefaultOrgID is used when CreateHumanUserRequest.OrgID is empty. // 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 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"` OAuthClientSecret string `json:",optional,env=ZITADEL_OAUTH_CLIENT_SECRET"`
// Google OAuth app credentials (register/social flow, PR 6). // Google OAuth app credentials (register/social flow, PR 6).
GoogleClientID string `json:",optional"` 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 is the ZITADEL external IdP id for Google (optional idp_id authorize hint).
GoogleIdPID string `json:",optional"` GoogleIdPID string `json:",optional"`
// LdapIdPID is the ZITADEL external IdP id for LDAP (optional idp_id authorize hint). // 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 overrides OIDC JWKS endpoint; defaults to {Issuer}/oauth/v2/keys.
JWKSUrl string `json:",optional"` JWKSUrl string `json:",optional"`
TimeoutSeconds int `json:",optional"` TimeoutSeconds int `json:",optional"`