386 lines
18 KiB
YAML
386 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": False,
|
|
"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": False,
|
|
"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}")
|
|
|
|
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)
|
|
|
|
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
|