titan-iac/services/keycloak/oneoffs/veles-realm-ensure-job.yaml
2026-06-09 18:00:57 -03:00

398 lines
18 KiB
YAML

# services/keycloak/oneoffs/veles-realm-ensure-job.yaml
# One-off job for sso/veles-realm-ensure-4.
# Purpose: create the Veles realm, groups, OIDC client, SMTP settings, and Vault client secret.
# Keep suspended until Veles Vault paths/policies have reconciled, then unsuspend once.
apiVersion: batch/v1
kind: Job
metadata:
name: veles-realm-ensure-4
namespace: sso
spec:
suspend: true
backoffLimit: 0
ttlSecondsAfterFinished: 3600
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/role: "sso-secrets"
vault.hashicorp.com/agent-inject-secret-keycloak-admin-env.sh: "kv/data/atlas/shared/keycloak-admin"
vault.hashicorp.com/agent-inject-template-keycloak-admin-env.sh: |
{{ with secret "kv/data/atlas/shared/keycloak-admin" }}
export KEYCLOAK_ADMIN="{{ .Data.data.username }}"
export KEYCLOAK_ADMIN_USER="{{ .Data.data.username }}"
export KEYCLOAK_ADMIN_PASSWORD="{{ .Data.data.password }}"
{{ end }}
{{ with secret "kv/data/atlas/shared/postmark-relay" }}
export KEYCLOAK_SMTP_USER="{{ index .Data.data "apikey" }}"
export KEYCLOAK_SMTP_PASSWORD="{{ index .Data.data "apikey" }}"
{{ end }}
spec:
serviceAccountName: mas-secrets-ensure
restartPolicy: Never
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: kubernetes.io/arch
operator: In
values: ["arm64"]
containers:
- name: configure
image: python:3.11-alpine
env:
- name: KEYCLOAK_SERVER
value: http://keycloak.sso.svc.cluster.local
- name: KEYCLOAK_REALM
value: veles
- name: KEYCLOAK_CLIENT_ID
value: veles-web
- name: KEYCLOAK_PUBLIC_ISSUER
value: https://sso.bstein.dev/realms/veles
- name: VELES_BASE_URL
value: https://veles.bstein.dev
- name: KEYCLOAK_SMTP_HOST
value: mail.bstein.dev
- name: KEYCLOAK_SMTP_PORT
value: "587"
- name: KEYCLOAK_SMTP_FROM
value: no-reply-veles@bstein.dev
- name: KEYCLOAK_SMTP_FROM_NAME
value: Veles
command: ["/bin/sh", "-c"]
args:
- |
set -eu
. /vault/secrets/keycloak-admin-env.sh
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
base_url = os.environ["KEYCLOAK_SERVER"].rstrip("/")
realm = os.environ["KEYCLOAK_REALM"]
client_id = os.environ["KEYCLOAK_CLIENT_ID"]
issuer = os.environ["KEYCLOAK_PUBLIC_ISSUER"]
veles_base_url = os.environ["VELES_BASE_URL"].rstrip("/")
admin_user = os.environ["KEYCLOAK_ADMIN_USER"]
admin_password = os.environ["KEYCLOAK_ADMIN_PASSWORD"]
def request(method, url, token=None, payload=None, headers=None, timeout=30):
data = None
req_headers = headers.copy() if headers else {}
if token:
req_headers["Authorization"] = f"Bearer {token}"
if payload is not None:
data = json.dumps(payload).encode()
req_headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
if not body:
return resp.status, None
return resp.status, json.loads(body.decode())
except urllib.error.HTTPError as exc:
raw = exc.read()
if not raw:
return exc.code, None
try:
return exc.code, json.loads(raw.decode())
except Exception:
return exc.code, {"raw": raw.decode(errors="replace")}
token_body = None
form = urllib.parse.urlencode(
{
"grant_type": "password",
"client_id": "admin-cli",
"username": admin_user,
"password": admin_password,
}
).encode()
for attempt in range(1, 11):
req = urllib.request.Request(
f"{base_url}/realms/master/protocol/openid-connect/token",
data=form,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
token_body = json.loads(resp.read().decode())
break
except urllib.error.URLError as exc:
if attempt == 10:
raise SystemExit(f"Keycloak token request failed after retries: {exc}")
time.sleep(attempt * 2)
token = token_body["access_token"]
smtp = {
"host": os.environ["KEYCLOAK_SMTP_HOST"],
"port": os.environ["KEYCLOAK_SMTP_PORT"],
"from": os.environ["KEYCLOAK_SMTP_FROM"],
"fromDisplayName": os.environ["KEYCLOAK_SMTP_FROM_NAME"],
"replyTo": os.environ["KEYCLOAK_SMTP_FROM"],
"replyToDisplayName": os.environ["KEYCLOAK_SMTP_FROM_NAME"],
"user": os.environ["KEYCLOAK_SMTP_USER"],
"password": os.environ["KEYCLOAK_SMTP_PASSWORD"],
"auth": "true",
"starttls": "true",
"ssl": "false",
}
status, realm_rep = request("GET", f"{base_url}/admin/realms/{realm}", token)
if status == 404:
create_payload = {
"realm": realm,
"enabled": True,
"registrationAllowed": True,
"resetPasswordAllowed": True,
"verifyEmail": True,
"loginWithEmailAllowed": True,
"duplicateEmailsAllowed": False,
"smtpServer": smtp,
}
status, body = request("POST", f"{base_url}/admin/realms", token, create_payload)
if status not in (201, 204, 409):
raise SystemExit(f"Realm create failed: status={status} body={body}")
status, realm_rep = request("GET", f"{base_url}/admin/realms/{realm}", token)
if status != 200 or not isinstance(realm_rep, dict):
raise SystemExit(f"Realm fetch failed: status={status}")
realm_rep.update(
{
"enabled": True,
"registrationAllowed": True,
"resetPasswordAllowed": True,
"verifyEmail": True,
"loginWithEmailAllowed": True,
"duplicateEmailsAllowed": False,
"smtpServer": smtp,
}
)
status, body = request("PUT", f"{base_url}/admin/realms/{realm}", token, realm_rep)
if status not in (200, 204):
raise SystemExit(f"Realm update failed: status={status} body={body}")
def ensure_group(name):
status, groups = request(
"GET",
f"{base_url}/admin/realms/{realm}/groups?search={urllib.parse.quote(name)}",
token,
)
if status != 200:
raise SystemExit(f"Group search failed for {name}: status={status}")
for group in groups or []:
if group.get("name") == name:
return group["id"]
status, body = request("POST", f"{base_url}/admin/realms/{realm}/groups", token, {"name": name})
if status not in (201, 204, 409):
raise SystemExit(f"Group create failed for {name}: status={status} body={body}")
status, groups = request(
"GET",
f"{base_url}/admin/realms/{realm}/groups?search={urllib.parse.quote(name)}",
token,
)
if status != 200:
raise SystemExit(f"Group lookup failed after create for {name}: status={status}")
for group in groups or []:
if group.get("name") == name:
return group["id"]
raise SystemExit(f"Group {name} not found after create")
def ensure_role(name):
status, role = request("GET", f"{base_url}/admin/realms/{realm}/roles/{urllib.parse.quote(name)}", token)
if status == 404:
status, body = request("POST", f"{base_url}/admin/realms/{realm}/roles", token, {"name": name})
if status not in (201, 204, 409):
raise SystemExit(f"Role create failed for {name}: status={status} body={body}")
status, role = request(
"GET",
f"{base_url}/admin/realms/{realm}/roles/{urllib.parse.quote(name)}",
token,
)
if status != 200 or not isinstance(role, dict):
raise SystemExit(f"Role lookup failed for {name}: status={status}")
return role
def ensure_group_role(group_id, role):
status, mappings = request(
"GET",
f"{base_url}/admin/realms/{realm}/groups/{group_id}/role-mappings/realm",
token,
)
if status != 200:
raise SystemExit(f"Group role mapping lookup failed: status={status}")
if any(mapping.get("name") == role["name"] for mapping in mappings or []):
return
status, body = request(
"POST",
f"{base_url}/admin/realms/{realm}/groups/{group_id}/role-mappings/realm",
token,
[role],
)
if status not in (200, 204):
raise SystemExit(f"Group role mapping failed for {role['name']}: status={status} body={body}")
def ensure_default_group(group_id, name):
status, groups = request("GET", f"{base_url}/admin/realms/{realm}/default-groups", token)
if status != 200:
raise SystemExit(f"Default group lookup failed: status={status}")
for group in groups or []:
if group.get("id") == group_id or group.get("name") == name:
return
status, body = request("PUT", f"{base_url}/admin/realms/{realm}/default-groups/{group_id}", token)
if status not in (200, 204):
raise SystemExit(f"Default group update failed for {name}: status={status} body={body}")
alpha_group_id = ensure_group("alpha")
admin_group_id = ensure_group("admin")
alpha_role = ensure_role("alpha")
admin_role = ensure_role("admin")
ensure_group_role(alpha_group_id, alpha_role)
ensure_group_role(admin_group_id, alpha_role)
ensure_group_role(admin_group_id, admin_role)
ensure_default_group(alpha_group_id, "alpha")
status, clients = request(
"GET",
f"{base_url}/admin/realms/{realm}/clients?clientId={urllib.parse.quote(client_id)}",
token,
)
if status != 200:
raise SystemExit(f"Client lookup failed: status={status}")
client_uuid = clients[0]["id"] if clients else None
client_payload = {
"clientId": client_id,
"enabled": True,
"protocol": "openid-connect",
"publicClient": False,
"standardFlowEnabled": True,
"implicitFlowEnabled": False,
"directAccessGrantsEnabled": False,
"serviceAccountsEnabled": False,
"redirectUris": [f"{veles_base_url}/*"],
"webOrigins": [veles_base_url],
"rootUrl": veles_base_url,
"baseUrl": "/",
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": f"{veles_base_url}/*",
},
}
if not client_uuid:
status, body = request("POST", f"{base_url}/admin/realms/{realm}/clients", token, client_payload)
if status not in (201, 204, 409):
raise SystemExit(f"Client create failed: status={status} body={body}")
status, clients = request(
"GET",
f"{base_url}/admin/realms/{realm}/clients?clientId={urllib.parse.quote(client_id)}",
token,
)
client_uuid = clients[0]["id"] if clients else None
if not client_uuid:
raise SystemExit("Client veles-web not found after create")
status, body = request(
"PUT",
f"{base_url}/admin/realms/{realm}/clients/{client_uuid}",
token,
client_payload,
)
if status not in (200, 204):
raise SystemExit(f"Client update failed: status={status} body={body}")
mapper_payload = {
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": False,
"config": {
"full.path": "false",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"claim.name": "groups",
"jsonType.label": "String",
},
}
status, mappers = request(
"GET",
f"{base_url}/admin/realms/{realm}/clients/{client_uuid}/protocol-mappers/models",
token,
)
if status != 200:
raise SystemExit(f"Mapper lookup failed: status={status}")
mapper_id = next((mapper.get("id") for mapper in mappers or [] if mapper.get("name") == "groups"), None)
if mapper_id:
mapper_payload["id"] = mapper_id
status, body = request(
"PUT",
f"{base_url}/admin/realms/{realm}/clients/{client_uuid}/protocol-mappers/models/{mapper_id}",
token,
mapper_payload,
)
else:
status, body = request(
"POST",
f"{base_url}/admin/realms/{realm}/clients/{client_uuid}/protocol-mappers/models",
token,
mapper_payload,
)
if status not in (200, 201, 204):
raise SystemExit(f"Mapper ensure failed: status={status} body={body}")
status, secret = request(
"GET",
f"{base_url}/admin/realms/{realm}/clients/{client_uuid}/client-secret",
token,
)
client_secret = (secret or {}).get("value")
if status != 200 or not client_secret:
raise SystemExit(f"Client secret fetch failed: status={status}")
vault_addr = os.environ.get("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200")
jwt = open("/var/run/secrets/kubernetes.io/serviceaccount/token", encoding="utf-8").read().strip()
login_payload = json.dumps({"jwt": jwt, "role": os.environ.get("VAULT_ROLE", "sso-secrets")}).encode()
req = urllib.request.Request(
f"{vault_addr}/v1/auth/kubernetes/login",
data=login_payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=20) as resp:
vault_token = json.loads(resp.read().decode())["auth"]["client_token"]
payload = {
"data": {
"client_id": client_id,
"client_secret": client_secret,
"issuer": issuer,
"realm": realm,
"required_groups": "alpha,admin",
}
}
req = urllib.request.Request(
f"{vault_addr}/v1/kv/data/atlas/veles/veles-oidc",
data=json.dumps(payload).encode(),
headers={"X-Vault-Token": vault_token, "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=20) as resp:
if resp.status not in (200, 204):
raise SystemExit(f"Vault write returned {resp.status}")
print("Veles Keycloak realm/client ready")
PY