feat(iam): read the Zitadel bootstrap token from .env

ZITADEL_BOOTSTRAP_TOKEN and ZITADEL_URL now live in .env and .env.example, the
same shape the Authentik variables use. The line is present and empty: the PAT
does not exist until the chart's setup job creates the iam-admin machine user on
the first sync, so a value cannot be minted ahead of time.

The provisioning script loads .env itself instead of relying on the caller to
source it, and resolves the token in a fixed order: ZITADEL_BOOTSTRAP_TOKEN,
then the cached file under ~/.config/zitadel, then the cluster Secret. The .env
value wins so a human can paste a rotated token without touching the cluster;
the cluster Secret remains the only source that always reflects a rotation.

.env stays git-ignored. It already holds the live Authentik secrets, and this
change does not move them.

Not exercised against a live instance: the kube-apiserver on talos-cp-1 is in
CrashLoopBackOff, so kubectl only works against cp-2 or cp-3 right now.
This commit is contained in:
2026-10-24 03:38:21 -07:00
parent 2f62351731
commit 979a413a5b
2 changed files with 56 additions and 5 deletions
+11
View File
@@ -31,6 +31,17 @@ AUTHENTIK_BOOTSTRAP_TOKEN=
# openssl rand -base64 24 # openssl rand -base64 24
AUTHENTIK_PG_PASSWORD= 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 ──────────────────────────────────────────────────────────────────────
MINIO_ROOT_USER= MINIO_ROOT_USER=
MINIO_ROOT_PASSWORD= MINIO_ROOT_PASSWORD=
+45 -5
View File
@@ -102,6 +102,46 @@ def secret_value(name: str, key: str) -> str:
return base64.b64decode(raw).decode() 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, def curl_json(method: str, url: str, token: str | None = None, body: dict | None = None,
timeout: int = 20) -> tuple[int, str]: timeout: int = 20) -> tuple[int, str]:
"""Call the API without putting a secret in the process argv. """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: def phase_users(args: argparse.Namespace) -> int:
pat_file = CONF_DIR / "iam-admin.pat" token = bootstrap_token()
if not pat_file.exists(): if not token:
die(f"{pat_file} missing — run the keys phase first") die("no bootstrap token — set ZITADEL_BOOTSTRAP_TOKEN in .env, or run the keys phase")
if not args.django_hash: if not args.django_hash:
die("pass --django-hash with the pbkdf2_sha256 value exported from Authentik") die("pass --django-hash with the pbkdf2_sha256 value exported from Authentik")
body = { body = {
@@ -380,8 +420,7 @@ def phase_users(args: argparse.Namespace) -> int:
"hashedPassword": {"hash": convert_hash(args.django_hash)}, "hashedPassword": {"hash": convert_hash(args.django_hash)},
"passwordChangeRequired": False, "passwordChangeRequired": False,
} }
code, payload = curl_json("POST", f"{BASE_URL}/v2/users/human", code, payload = curl_json("POST", f"{BASE_URL}/v2/users/human", token=token, body=body)
token=pat_file.read_text().strip(), body=body)
if code in (200, 201): if code in (200, 201):
print(f" {args.username}: created") print(f" {args.username}: created")
return 0 return 0
@@ -429,6 +468,7 @@ def main() -> int:
parser.add_argument("--django-hash", help="pbkdf2_sha256 value exported from Authentik") parser.add_argument("--django-hash", help="pbkdf2_sha256 value exported from Authentik")
args = parser.parse_args() args = parser.parse_args()
load_env_file()
need("kubectl") need("kubectl")
if args.phase == "all": if args.phase == "all":
if not args.org_id: if not args.org_id: