# services/keycloak/realm-settings-job.yaml apiVersion: batch/v1 kind: Job metadata: name: keycloak-realm-settings-30 namespace: sso spec: backoffLimit: 0 template: metadata: annotations: vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-pre-populate-only: "true" vault.hashicorp.com/role: "sso" vault.hashicorp.com/agent-inject-secret-keycloak-env.sh: "kv/data/atlas/shared/keycloak-admin" vault.hashicorp.com/agent-inject-template-keycloak-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/sso/keycloak-db" }} export KC_DB_URL_DATABASE="{{ .Data.data.POSTGRES_DATABASE }}" export KC_DB_USERNAME="{{ .Data.data.POSTGRES_USER }}" export KC_DB_PASSWORD="{{ .Data.data.POSTGRES_PASSWORD }}" {{ end }} {{ with secret "kv/data/atlas/shared/portal-e2e-client" }} export PORTAL_E2E_CLIENT_ID="{{ .Data.data.client_id }}" export PORTAL_E2E_CLIENT_SECRET="{{ .Data.data.client_secret }}" {{ end }} {{ with secret "kv/data/atlas/sso/openldap-admin" }} export LDAP_ADMIN_PASSWORD="{{ .Data.data.LDAP_ADMIN_PASSWORD }}" export LDAP_CONFIG_PASSWORD="{{ .Data.data.LDAP_CONFIG_PASSWORD }}" export LDAP_BIND_PASSWORD="${LDAP_ADMIN_PASSWORD}" {{ end }} {{ with secret "kv/data/atlas/shared/postmark-relay" }} export KEYCLOAK_SMTP_USER="{{ index .Data.data "relay-username" }}" export KEYCLOAK_SMTP_PASSWORD="{{ index .Data.data "relay-password" }}" {{ end }} spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: hardware operator: In values: ["rpi5","rpi4"] - key: node-role.kubernetes.io/worker operator: Exists restartPolicy: Never serviceAccountName: sso-vault containers: - name: configure image: python:3.11-alpine env: - name: KEYCLOAK_SERVER value: http://keycloak.sso.svc.cluster.local - name: KEYCLOAK_REALM value: atlas - name: KEYCLOAK_SMTP_HOST value: mail.bstein.dev - name: KEYCLOAK_SMTP_PORT value: "587" - name: KEYCLOAK_SMTP_FROM value: no-reply-sso@bstein.dev - name: KEYCLOAK_SMTP_FROM_NAME value: Atlas SSO - name: KEYCLOAK_SMTP_REPLY_TO value: no-reply-sso@bstein.dev - name: KEYCLOAK_SMTP_REPLY_TO_NAME value: Atlas SSO command: ["/bin/sh", "-c"] args: - | set -euo pipefail . /vault/secrets/keycloak-env.sh python - <<'PY' import json import os import time import urllib.parse import urllib.error import urllib.request base_url = os.environ["KEYCLOAK_SERVER"].rstrip("/") realm = os.environ["KEYCLOAK_REALM"] admin_user = os.environ["KEYCLOAK_ADMIN_USER"] admin_password = os.environ["KEYCLOAK_ADMIN_PASSWORD"] def http_json(method: str, url: str, token: str, payload=None): data = None headers = {"Authorization": f"Bearer {token}"} if payload is not None: data = json.dumps(payload).encode() headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) 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_data = urllib.parse.urlencode( { "grant_type": "password", "client_id": "admin-cli", "username": admin_user, "password": admin_password, } ).encode() token_body = None for attempt in range(1, 11): token_req = urllib.request.Request( f"{base_url}/realms/master/protocol/openid-connect/token", data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST", ) try: with urllib.request.urlopen(token_req, timeout=10) as resp: token_body = json.loads(resp.read().decode()) break except urllib.error.HTTPError as exc: body = exc.read().decode(errors="replace") raise SystemExit(f"Token request failed: status={exc.code} body={body}") except urllib.error.URLError as exc: if attempt == 10: raise SystemExit(f"Token request failed after retries: {exc}") time.sleep(attempt * 2) if not token_body: raise SystemExit("Token request failed without response") access_token = token_body["access_token"] # Update realm settings safely by fetching the full realm representation first. realm_url = f"{base_url}/admin/realms/{realm}" status, realm_rep = http_json("GET", realm_url, access_token) if status != 200 or not realm_rep: raise SystemExit(f"Unable to fetch realm {realm} (status={status})") realm_rep["resetPasswordAllowed"] = True smtp = realm_rep.get("smtpServer") or {} smtp.update( { "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_REPLY_TO"], "replyToDisplayName": os.environ["KEYCLOAK_SMTP_REPLY_TO_NAME"], "user": os.environ["KEYCLOAK_SMTP_USER"], "password": os.environ["KEYCLOAK_SMTP_PASSWORD"], "auth": "true", "starttls": "true", "ssl": "false", } ) realm_rep["smtpServer"] = smtp status, _ = http_json("PUT", realm_url, access_token, realm_rep) if status not in (200, 204): raise SystemExit(f"Unexpected realm update response: {status}") # Ensure required custom user-profile attributes exist. profile_url = f"{base_url}/admin/realms/{realm}/users/profile" status, profile = http_json("GET", profile_url, access_token) if status == 200 and isinstance(profile, dict): attrs = profile.get("attributes") if not isinstance(attrs, list): attrs = [] required_attrs = [ { "name": "vaultwarden_email", "displayName": "Vaultwarden Email", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"email": {}, "length": {"max": 255}}, }, { "name": "vaultwarden_status", "displayName": "Vaultwarden Status", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 64}}, }, { "name": "vaultwarden_synced_at", "displayName": "Vaultwarden Last Synced", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 64}}, }, { "name": "mailu_email", "displayName": "Atlas Mailbox", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"email": {}, "length": {"max": 255}}, }, { "name": "mailu_app_password", "displayName": "Atlas Mail App Password", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 255}}, }, { "name": "nextcloud_mail_primary_email", "displayName": "Nextcloud Mail Primary Email", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"email": {}, "length": {"max": 255}}, }, { "name": "nextcloud_mail_account_count", "displayName": "Nextcloud Mail Account Count", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 32}}, }, { "name": "nextcloud_mail_synced_at", "displayName": "Nextcloud Mail Last Synced", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 64}}, }, { "name": "wger_password", "displayName": "Wger Password", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 255}}, }, { "name": "wger_password_updated_at", "displayName": "Wger Password Updated At", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 64}}, }, { "name": "firefly_password", "displayName": "Firefly Password", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 255}}, }, { "name": "firefly_password_updated_at", "displayName": "Firefly Password Updated At", "multivalued": False, "annotations": {"group": "user-metadata"}, "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 64}}, }, ] def has_attr(name: str) -> bool: return any(isinstance(item, dict) and item.get("name") == name for item in attrs) updated = False for attr in required_attrs: if not has_attr(attr.get("name", "")): attrs.append(attr) updated = True if updated: profile["attributes"] = attrs status, _ = http_json("PUT", profile_url, access_token, profile) if status not in (200, 204): raise SystemExit(f"Unexpected user-profile update response: {status}") def find_group(group_name: str): status, groups = http_json( "GET", f"{base_url}/admin/realms/{realm}/groups?search={urllib.parse.quote(group_name)}", access_token, ) if status != 200 or not isinstance(groups, list): return None for item in groups: if isinstance(item, dict) and item.get("name") == group_name: return item return None def ensure_group(group_name: str): group = find_group(group_name) if group: return group status, _ = http_json( "POST", f"{base_url}/admin/realms/{realm}/groups", access_token, {"name": group_name}, ) if status not in (201, 204): raise SystemExit(f"Unexpected group create response for {group_name}: {status}") return find_group(group_name) # Ensure basic realm groups exist for provisioning. ensure_group("dev") ensure_group("admin") planka_group = ensure_group("planka-users") if planka_group and planka_group.get("id"): group_id = planka_group["id"] status, default_groups = http_json( "GET", f"{base_url}/admin/realms/{realm}/default-groups", access_token, ) default_ids = set() if status == 200 and isinstance(default_groups, list): for item in default_groups: if isinstance(item, dict) and item.get("id"): default_ids.add(item["id"]) if group_id not in default_ids: status, _ = http_json( "PUT", f"{base_url}/admin/realms/{realm}/default-groups/{group_id}", access_token, ) if status not in (200, 201, 204): status, _ = http_json( "POST", f"{base_url}/admin/realms/{realm}/default-groups/{group_id}", access_token, ) if status not in (200, 201, 204): raise SystemExit( f"Unexpected default-group update response for planka-users: {status}" ) # Ensure all existing users are in the planka-users group. first = 0 page_size = 100 while True: status, users = http_json( "GET", f"{base_url}/admin/realms/{realm}/users?first={first}&max={page_size}", access_token, ) if status != 200 or not isinstance(users, list) or not users: break for user in users: user_id = user.get("id") if isinstance(user, dict) else None if not user_id: continue status, groups = http_json( "GET", f"{base_url}/admin/realms/{realm}/users/{user_id}/groups", access_token, ) if status == 200 and isinstance(groups, list): already = any(isinstance(g, dict) and g.get("id") == group_id for g in groups) if already: continue status, _ = http_json( "PUT", f"{base_url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}", access_token, ) if status not in (200, 201, 204): status, _ = http_json( "POST", f"{base_url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}", access_token, ) if status not in (200, 201, 204): raise SystemExit( f"Unexpected group membership update for user {user_id}: {status}" ) if len(users) < page_size: break first += page_size # Ensure Planka client exposes groups in userinfo for role mapping. status, clients = http_json( "GET", f"{base_url}/admin/realms/{realm}/clients?clientId=planka", access_token, ) planka_client = None if status == 200 and isinstance(clients, list): for item in clients: if isinstance(item, dict) and item.get("clientId") == "planka": planka_client = item break if planka_client: client_id = planka_client.get("id") 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 = http_json( "GET", f"{base_url}/admin/realms/{realm}/clients/{client_id}/protocol-mappers/models", access_token, ) existing = None if status == 200 and isinstance(mappers, list): for item in mappers: if isinstance(item, dict) and item.get("name") == mapper_payload["name"]: existing = item break if existing and existing.get("id"): mapper_payload["id"] = existing["id"] status, _ = http_json( "PUT", f"{base_url}/admin/realms/{realm}/clients/{client_id}/protocol-mappers/models/{existing['id']}", access_token, mapper_payload, ) if status not in (200, 204): raise SystemExit(f"Unexpected protocol mapper update response: {status}") else: status, _ = http_json( "POST", f"{base_url}/admin/realms/{realm}/clients/{client_id}/protocol-mappers/models", access_token, mapper_payload, ) if status not in (201, 204): raise SystemExit(f"Unexpected protocol mapper create response: {status}") # Ensure MFA is on by default for newly-created users. status, required_actions = http_json( "GET", f"{base_url}/admin/realms/{realm}/authentication/required-actions", access_token, ) if status == 200 and isinstance(required_actions, list): for action in required_actions: if not isinstance(action, dict): continue if action.get("alias") != "CONFIGURE_TOTP": continue if action.get("enabled") is True and action.get("defaultAction") is True: break action["enabled"] = True action["defaultAction"] = True status, _ = http_json( "PUT", f"{base_url}/admin/realms/{realm}/authentication/required-actions/CONFIGURE_TOTP", access_token, action, ) if status not in (200, 204): raise SystemExit( f"Unexpected required-action update response for CONFIGURE_TOTP: {status}" ) # Disable Identity Provider Redirector in the browser flow for this realm. status, executions = http_json( "GET", f"{base_url}/admin/realms/{realm}/authentication/flows/browser/executions", access_token, ) if status == 200 and executions: for ex in executions: if ex.get("providerId") != "identity-provider-redirector": continue if ex.get("requirement") == "DISABLED": continue ex["requirement"] = "DISABLED" status, _ = http_json( "PUT", f"{base_url}/admin/realms/{realm}/authentication/flows/browser/executions", access_token, ex, ) if status not in (200, 204): raise SystemExit( f"Unexpected execution update response for identity-provider-redirector: {status}" ) PY volumeMounts: