217 lines
9.6 KiB
YAML
217 lines
9.6 KiB
YAML
# services/keycloak/oneoffs/user-overrides-job.yaml
|
|
# One-off job for sso/keycloak-user-overrides-9.
|
|
# Purpose: keycloak user overrides 9 (see container args/env in this file).
|
|
# Run by setting spec.suspend to false, reconcile, then set it back to true.
|
|
# Safe to delete the finished Job/pod; it should not run continuously.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: keycloak-user-overrides-9
|
|
namespace: sso
|
|
spec:
|
|
suspend: true
|
|
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 "apikey" }}"
|
|
export KEYCLOAK_SMTP_PASSWORD="{{ index .Data.data "apikey" }}"
|
|
{{ 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:
|