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:
@@ -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=
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user