titan-iac/services/keycloak/ldap-federation-job.yaml

251 lines
10 KiB
YAML
Raw Normal View History

# services/keycloak/ldap-federation-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: keycloak-ldap-federation-1
namespace: sso
spec:
backoffLimit: 2
template:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: hardware
operator: In
values: ["rpi5","rpi4"]
- key: node-role.kubernetes.io/worker
operator: Exists
restartPolicy: OnFailure
containers:
- name: configure
image: python:3.11-alpine
imagePullPolicy: IfNotPresent
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: LDAP_URL
value: ldap://openldap.sso.svc.cluster.local:389
- name: LDAP_BIND_DN
value: cn=admin,dc=bstein,dc=dev
- name: LDAP_BIND_PASSWORD
valueFrom:
secretKeyRef:
name: openldap-admin
key: LDAP_ADMIN_PASSWORD
- name: LDAP_USERS_DN
value: ou=users,dc=bstein,dc=dev
- name: LDAP_GROUPS_DN
value: ou=groups,dc=bstein,dc=dev
command: ["/bin/sh", "-c"]
args:
- |
set -euo pipefail
python - <<'PY'
import json
import os
import time
import urllib.parse
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"]
ldap_url = os.environ["LDAP_URL"]
ldap_bind_dn = os.environ["LDAP_BIND_DN"]
ldap_bind_password = os.environ["LDAP_BIND_PASSWORD"]
ldap_users_dn = os.environ["LDAP_USERS_DN"]
ldap_groups_dn = os.environ["LDAP_GROUPS_DN"]
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)
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read()
if not body:
return resp.status, None, dict(resp.headers)
return resp.status, json.loads(body.decode()), dict(resp.headers)
def get_token():
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",
)
with urllib.request.urlopen(token_req, timeout=30) as resp:
token_body = json.loads(resp.read().decode())
return token_body["access_token"]
def wait_for_keycloak():
for _ in range(60):
try:
token = get_token()
if token:
return token
except Exception:
time.sleep(2)
raise SystemExit("Keycloak not ready")
token = wait_for_keycloak()
# Find existing LDAP user federation provider (if any)
status, components, _ = http_json(
"GET",
f"{base_url}/admin/realms/{realm}/components?type=org.keycloak.storage.UserStorageProvider",
token,
)
if status != 200:
raise SystemExit(f"Unexpected components response: {status}")
components = components or []
ldap_component = None
for c in components:
if c.get("providerId") == "ldap" and c.get("name") in ("openldap", "ldap"):
ldap_component = c
break
ldap_component_id = ldap_component["id"] if ldap_component else None
desired = {
"name": "openldap",
"providerId": "ldap",
"providerType": "org.keycloak.storage.UserStorageProvider",
"parentId": realm,
"config": {
"enabled": ["true"],
"priority": ["0"],
"importEnabled": ["true"],
"editMode": ["WRITABLE"],
"syncRegistrations": ["true"],
"vendor": ["other"],
"connectionUrl": [ldap_url],
"bindDn": [ldap_bind_dn],
"bindCredential": [ldap_bind_password],
"authType": ["simple"],
"usersDn": [ldap_users_dn],
"searchScope": ["1"],
"pagination": ["true"],
"usernameLDAPAttribute": ["uid"],
"rdnLDAPAttribute": ["uid"],
"uuidLDAPAttribute": ["entryUUID"],
"userObjectClasses": ["inetOrgPerson, organizationalPerson, person, top"],
"trustEmail": ["true"],
"useTruststoreSpi": ["never"],
"connectionPooling": ["true"],
"cachePolicy": ["DEFAULT"],
"useKerberosForPasswordAuthentication": ["false"],
"allowKerberosAuthentication": ["false"],
},
}
if ldap_component:
desired["id"] = ldap_component["id"]
print(f"Updating LDAP federation provider: {desired['id']}")
status, _, _ = http_json(
"PUT",
f"{base_url}/admin/realms/{realm}/components/{desired['id']}",
token,
desired,
)
if status not in (200, 204):
raise SystemExit(f"Unexpected update status: {status}")
else:
print("Creating LDAP federation provider")
status, _, headers = http_json(
"POST",
f"{base_url}/admin/realms/{realm}/components",
token,
desired,
)
if status not in (201, 204):
raise SystemExit(f"Unexpected create status: {status}")
location = headers.get("Location", "")
if location:
ldap_component_id = location.rstrip("/").split("/")[-1]
# Ensure a basic LDAP group mapper exists (optional but harmless).
if not ldap_component_id:
print("WARNING: unable to determine LDAP component id; skipping group mapper")
raise SystemExit(0)
status, components, _ = http_json(
"GET",
f"{base_url}/admin/realms/{realm}/components?type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
token,
)
components = components or []
group_mapper = None
for c in components:
if c.get("name") == "openldap-groups" and c.get("parentId") == ldap_component_id:
group_mapper = c
break
mapper_payload = {
"name": "openldap-groups",
"providerId": "group-ldap-mapper",
"providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
"parentId": ldap_component_id,
"config": {
"groups.dn": [ldap_groups_dn],
"group.name.ldap.attribute": ["cn"],
"group.object.classes": ["groupOfNames"],
"membership.ldap.attribute": ["member"],
"membership.attribute.type": ["DN"],
"mode": ["LDAP_ONLY"],
"user.roles.retrieve.strategy": ["LOAD_GROUPS_BY_MEMBER_ATTRIBUTE"],
"preserve.group.inheritance": ["true"],
},
}
if group_mapper:
mapper_payload["id"] = group_mapper["id"]
mapper_payload["parentId"] = group_mapper.get("parentId", mapper_payload["parentId"])
print(f"Updating LDAP group mapper: {mapper_payload['id']}")
status, _, _ = http_json(
"PUT",
f"{base_url}/admin/realms/{realm}/components/{mapper_payload['id']}",
token,
mapper_payload,
)
if status not in (200, 204):
raise SystemExit(f"Unexpected group mapper update status: {status}")
else:
print("Creating LDAP group mapper")
status, _, _ = http_json(
"POST",
f"{base_url}/admin/realms/{realm}/components",
token,
mapper_payload,
)
if status not in (201, 204):
raise SystemExit(f"Unexpected group mapper create status: {status}")
PY