titan-iac/services/keycloak/user-overrides-job.yaml

212 lines
9.4 KiB
YAML

# services/keycloak/user-overrides-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: keycloak-user-overrides-8
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: OVERRIDE_USERNAME
value: bstein
- name: OVERRIDE_MAILU_EMAIL
value: brad@bstein.dev
command: ["/bin/sh", "-c"]
args:
- |
set -euo pipefail
. /vault/secrets/keycloak-env.sh
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")
needs_update = True
if isinstance(existing, list) and existing and existing[0] == override_mailu_email:
needs_update = False
if isinstance(existing, str) and existing == override_mailu_email:
needs_update = False
if needs_update:
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}")
# Ensure the user is in the admin and planka-users groups.
def ensure_group(group_name: str) -> None:
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):
raise SystemExit("Unable to fetch groups")
group_id = ""
for item in groups:
if isinstance(item, dict) and item.get("name") == group_name:
group_id = item.get("id") or ""
break
if not group_id:
raise SystemExit(f"{group_name} group not found")
status, memberships = http_json(
"GET",
f"{base_url}/admin/realms/{realm}/users/{user_id}/groups",
access_token,
)
if status != 200 or not isinstance(memberships, list):
raise SystemExit("Unable to read user groups")
already = any(
isinstance(item, dict) and item.get("id") == group_id for item in memberships
)
if already:
return
status, _ = http_json(
"PUT",
f"{base_url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}",
access_token,
)
if status not in (200, 204):
raise SystemExit(
f"Unexpected group update response for {group_name}: {status}"
)
for group in ("admin", "planka-users"):
ensure_group(group)
PY
volumeMounts: