titan-iac/services/keycloak/realm-settings-job.yaml

597 lines
28 KiB
YAML
Raw Normal View History

2026-01-02 03:38:50 -03:00
# services/keycloak/realm-settings-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
2026-01-21 03:03:32 -03:00
name: keycloak-realm-settings-34
2026-01-02 03:38:50 -03:00
namespace: sso
spec:
backoffLimit: 0
2026-01-02 03:38:50 -03:00
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
2026-01-14 14:29:29 -03:00
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 }}
2026-01-02 03:38:50 -03:00
spec:
2026-01-02 03:45:44 -03:00
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
2026-01-02 03:38:50 -03:00
containers:
- name: configure
2026-01-02 04:03:27 -03:00
image: python:3.11-alpine
2026-01-02 03:38:50 -03:00
env:
2026-01-02 03:55:08 -03:00
- name: KEYCLOAK_SERVER
2026-01-02 03:49:19 -03:00
value: http://keycloak.sso.svc.cluster.local
2026-01-02 03:38:50 -03:00
- name: KEYCLOAK_REALM
value: atlas
- name: KEYCLOAK_SMTP_HOST
value: mail.bstein.dev
2026-01-02 03:38:50 -03:00
- name: KEYCLOAK_SMTP_PORT
2026-01-14 10:07:31 -03:00
value: "587"
2026-01-02 03:38:50 -03:00
- name: KEYCLOAK_SMTP_FROM
2026-01-14 10:07:31 -03:00
value: no-reply-sso@bstein.dev
2026-01-02 03:38:50 -03:00
- name: KEYCLOAK_SMTP_FROM_NAME
value: Atlas SSO
- name: KEYCLOAK_SMTP_REPLY_TO
2026-01-14 10:07:31 -03:00
value: no-reply-sso@bstein.dev
2026-01-02 03:38:50 -03:00
- name: KEYCLOAK_SMTP_REPLY_TO_NAME
value: Atlas SSO
command: ["/bin/sh", "-c"]
args:
- |
set -euo pipefail
. /vault/secrets/keycloak-env.sh
2026-01-02 04:03:27 -03:00
python - <<'PY'
import json
import os
2026-01-17 01:41:39 -03:00
import time
2026-01-02 04:03:27 -03:00
import urllib.parse
import urllib.error
2026-01-02 04:03:27 -03:00
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")}
2026-01-02 04:03:27 -03:00
token_data = urllib.parse.urlencode(
{
"grant_type": "password",
"client_id": "admin-cli",
"username": admin_user,
"password": admin_password,
}
).encode()
2026-01-17 01:41:39 -03:00
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")
2026-01-02 04:03:27 -03:00
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(
{
2026-01-02 04:03:27 -03:00
"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"],
2026-01-14 10:07:31 -03:00
"user": os.environ["KEYCLOAK_SMTP_USER"],
"password": os.environ["KEYCLOAK_SMTP_PASSWORD"],
"auth": "true",
"starttls": "true",
2026-01-02 04:03:27 -03:00
"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}")
2026-01-02 04:03:27 -03:00
2026-01-03 03:32:38 -03:00
# 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}},
},
2026-01-18 00:47:38 -03:00
{
"name": "mailu_enabled",
"displayName": "Atlas Mailbox Enabled",
"multivalued": False,
"annotations": {"group": "user-metadata"},
"permissions": {"view": ["admin"], "edit": ["admin"]},
"validations": {"length": {"max": 16}},
},
{
"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}},
2026-01-16 23:52:56 -03:00
},
{
"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:
2026-01-03 03:32:38 -03:00
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):
2026-01-03 03:32:38 -03:00
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
2026-01-03 03:32:38 -03:00
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")
ensure_group("demo")
ensure_group("test")
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
2026-01-03 03:32:38 -03:00
2026-01-12 23:13:30 -03:00
# 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}")
2026-01-21 02:57:40 -03:00
# Ensure mailu_email overrides email claim for service clients.
excluded_email_clients = {
"account",
"account-console",
"admin-cli",
"security-admin-console",
"realm-management",
"broker",
}
status, clients = http_json(
"GET",
f"{base_url}/admin/realms/{realm}/clients",
access_token,
)
if status == 200 and isinstance(clients, list):
for client in clients:
if not isinstance(client, dict):
continue
if client.get("protocol") != "openid-connect":
continue
client_name = client.get("clientId") if isinstance(client.get("clientId"), str) else ""
if not client_name or client_name in excluded_email_clients:
continue
client_id = client.get("id")
if not client_id:
continue
email_mapper = {
"name": "mailu-email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": False,
"config": {
"user.attribute": "mailu_email",
"claim.name": "email",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"multivalued": "false",
"aggregate.attrs": "false",
},
}
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") == email_mapper["name"]:
existing = item
break
if existing and existing.get("id"):
email_mapper["id"] = existing["id"]
status, _ = http_json(
"PUT",
f"{base_url}/admin/realms/{realm}/clients/{client_id}/protocol-mappers/models/{existing['id']}",
access_token,
email_mapper,
)
if status not in (200, 204):
raise SystemExit(f"Unexpected mailu email mapper update response: {status}")
else:
status, _ = http_json(
"POST",
f"{base_url}/admin/realms/{realm}/clients/{client_id}/protocol-mappers/models",
access_token,
email_mapper,
)
if status not in (201, 204):
raise SystemExit(f"Unexpected mailu email 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,
2026-01-02 04:03:27 -03:00
)
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}"
)
2026-01-02 04:03:27 -03:00
PY
2026-01-15 03:58:03 -03:00
volumeMounts: