diff --git a/.env.example b/.env.example index 433188e..48e344c 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,17 @@ AUTHENTIK_BOOTSTRAP_TOKEN= # openssl rand -base64 24 AUTHENTIK_PG_PASSWORD= +# ── ZITADEL (Authentik replacement) ───────────────────────────────────────────── +# Instance URL, same hostname as the OIDC issuer. +ZITADEL_URL="https://zitadel.riotpiao.com" + +# PAT of the 'iam-admin' machine user, created by the chart's setup job on the +# first sync. Leave empty until then. Read it with: +# kubectl -n iam get secret iam-admin-pat -o jsonpath='{.data.pat}' | base64 -d +# scripts/iam/zitadel-provision.py reads this variable first, then the cluster +# Secret. The masterkey and the client secrets never live in this file. +ZITADEL_BOOTSTRAP_TOKEN= + # ── MinIO ────────────────────────────────────────────────────────────────────── MINIO_ROOT_USER= MINIO_ROOT_PASSWORD= diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py index 73fa96f..58a2488 100755 --- a/scripts/iam/zitadel-provision.py +++ b/scripts/iam/zitadel-provision.py @@ -102,6 +102,46 @@ def secret_value(name: str, key: str) -> str: return base64.b64decode(raw).decode() +def load_env_file(path: Path = REPO / ".env") -> None: + """Load KEY=VALUE lines from .env without overriding the real environment. + + The file is git-ignored and holds the bootstrap token a human pastes in. + Sourcing it in the shell would work too, but then every phase depends on the + caller remembering to do it. + """ + if not path.exists(): + return + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def bootstrap_token() -> str | None: + """Resolve the iam-admin PAT: environment, then cached file, then cluster. + + Order matters. ZITADEL_BOOTSTRAP_TOKEN in .env is filled by a human after the + first sync and wins. The cached file under ~/.config/zitadel keeps a run + working when the API server is unreachable. The cluster Secret is the last + resort and the only source that always reflects a rotation. + """ + token = os.environ.get("ZITADEL_BOOTSTRAP_TOKEN", "").strip() + if token: + return token + cached = CONF_DIR / "iam-admin.pat" + if cached.exists(): + return cached.read_text().strip() + try: + return secret_value("iam-admin-pat", ".data.pat").strip() + except SystemExit: + return None + + def curl_json(method: str, url: str, token: str | None = None, body: dict | None = None, timeout: int = 20) -> tuple[int, str]: """Call the API without putting a secret in the process argv. @@ -368,9 +408,9 @@ def convert_hash(django_hash: str) -> str: def phase_users(args: argparse.Namespace) -> int: - pat_file = CONF_DIR / "iam-admin.pat" - if not pat_file.exists(): - die(f"{pat_file} missing — run the keys phase first") + token = bootstrap_token() + if not token: + die("no bootstrap token — set ZITADEL_BOOTSTRAP_TOKEN in .env, or run the keys phase") if not args.django_hash: die("pass --django-hash with the pbkdf2_sha256 value exported from Authentik") body = { @@ -380,8 +420,7 @@ def phase_users(args: argparse.Namespace) -> int: "hashedPassword": {"hash": convert_hash(args.django_hash)}, "passwordChangeRequired": False, } - code, payload = curl_json("POST", f"{BASE_URL}/v2/users/human", - token=pat_file.read_text().strip(), body=body) + code, payload = curl_json("POST", f"{BASE_URL}/v2/users/human", token=token, body=body) if code in (200, 201): print(f" {args.username}: created") return 0 @@ -429,6 +468,7 @@ def main() -> int: parser.add_argument("--django-hash", help="pbkdf2_sha256 value exported from Authentik") args = parser.parse_args() + load_env_file() need("kubectl") if args.phase == "all": if not args.org_id: