diff --git a/services/keycloak/kustomization.yaml b/services/keycloak/kustomization.yaml index 5fb05ef..05b410d 100644 --- a/services/keycloak/kustomization.yaml +++ b/services/keycloak/kustomization.yaml @@ -8,5 +8,6 @@ resources: - deployment.yaml - realm-settings-job.yaml - ldap-federation-job.yaml + - user-overrides-job.yaml - service.yaml - ingress.yaml diff --git a/services/keycloak/user-overrides-job.yaml b/services/keycloak/user-overrides-job.yaml new file mode 100644 index 0000000..43813ee --- /dev/null +++ b/services/keycloak/user-overrides-job.yaml @@ -0,0 +1,145 @@ +# services/keycloak/user-overrides-job.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: keycloak-user-overrides-1 + namespace: sso +spec: + backoffLimit: 0 + template: + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: hardware + operator: In + values: ["rpi5", "rpi4"] + - key: node-role.kubernetes.io/worker + operator: Exists + restartPolicy: Never + 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_ADMIN_USER + valueFrom: + secretKeyRef: + name: keycloak-admin + key: username + - name: KEYCLOAK_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-admin + key: password + - name: OVERRIDE_USERNAME + value: bstein + - name: OVERRIDE_MAILU_EMAIL + value: brad@bstein.dev + command: ["/bin/sh", "-c"] + args: + - | + set -euo pipefail + python - <<'PY' + import json + import os + 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"] + + override_username = os.environ["OVERRIDE_USERNAME"].strip() + override_mailu_email = os.environ["OVERRIDE_MAILU_EMAIL"].strip() + if not override_username or not override_mailu_email: + raise SystemExit("Missing override inputs") + + 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_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()) + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace") + raise SystemExit(f"Token request failed: status={exc.code} body={body}") + access_token = token_body["access_token"] + + # Find target user id. + status, users = http_json( + "GET", + f"{base_url}/admin/realms/{realm}/users?username={urllib.parse.quote(override_username)}&exact=true&max=1", + access_token, + ) + if status != 200 or not isinstance(users, list) or not users: + raise SystemExit(f"User not found: {override_username}") + user = users[0] if isinstance(users[0], dict) else None + user_id = (user or {}).get("id") or "" + if not user_id: + raise SystemExit("User id missing") + + # Fetch full user and update only attributes. + status, full = http_json("GET", f"{base_url}/admin/realms/{realm}/users/{user_id}", access_token) + if status != 200 or not isinstance(full, dict): + raise SystemExit("Unable to fetch user") + + attrs = full.get("attributes") or {} + if not isinstance(attrs, dict): + attrs = {} + existing = attrs.get("mailu_email") + if isinstance(existing, list) and existing and existing[0] == override_mailu_email: + raise SystemExit(0) + if isinstance(existing, str) and existing == override_mailu_email: + raise SystemExit(0) + + attrs["mailu_email"] = [override_mailu_email] + status, _ = http_json( + "PUT", + f"{base_url}/admin/realms/{realm}/users/{user_id}", + access_token, + {"attributes": attrs}, + ) + if status not in (200, 204): + raise SystemExit(f"Unexpected user update response: {status}") + PY