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