diff --git a/services/keycloak/kustomization.yaml b/services/keycloak/kustomization.yaml index 1e5fd38..5fb05ef 100644 --- a/services/keycloak/kustomization.yaml +++ b/services/keycloak/kustomization.yaml @@ -7,5 +7,6 @@ resources: - pvc.yaml - deployment.yaml - realm-settings-job.yaml + - ldap-federation-job.yaml - service.yaml - ingress.yaml diff --git a/services/keycloak/ldap-federation-job.yaml b/services/keycloak/ldap-federation-job.yaml new file mode 100644 index 0000000..ad90ac7 --- /dev/null +++ b/services/keycloak/ldap-federation-job.yaml @@ -0,0 +1,250 @@ +# 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 diff --git a/services/openldap/bootstrap-job.yaml b/services/openldap/bootstrap-job.yaml new file mode 100644 index 0000000..2f94f88 --- /dev/null +++ b/services/openldap/bootstrap-job.yaml @@ -0,0 +1,63 @@ +# services/openldap/bootstrap-job.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: openldap-bootstrap-1 + namespace: sso +spec: + backoffLimit: 3 + template: + spec: + restartPolicy: OnFailure + nodeSelector: + kubernetes.io/arch: arm64 + node-role.kubernetes.io/worker: "true" + containers: + - name: bootstrap + image: docker.io/osixia/openldap:1.5.0 + imagePullPolicy: IfNotPresent + env: + - name: LDAP_DOMAIN + value: bstein.dev + - name: LDAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: openldap-admin + key: LDAP_ADMIN_PASSWORD + command: ["/bin/sh", "-c"] + args: + - | + set -euo pipefail + + domain="${LDAP_DOMAIN}" + base_dn="$(printf '%s' "${domain}" | awk -F. '{for (i=1;i<=NF;i++) printf("%sdc=%s", (i==1?"":","), $i)}')" + admin_dn="cn=admin,${base_dn}" + ldap_uri="ldap://openldap.sso.svc.cluster.local:389" + + echo "Waiting for OpenLDAP..." + for i in $(seq 1 60); do + if ldapsearch -x -H "${ldap_uri}" -b "${base_dn}" -s base '(objectClass=*)' dn >/dev/null 2>&1; then + break + fi + sleep 2 + done + + ensure_ou() { + local ou_name="${1}" + local ou_dn="ou=${ou_name},${base_dn}" + + if ldapsearch -x -H "${ldap_uri}" -D "${admin_dn}" -w "${LDAP_ADMIN_PASSWORD}" -b "${ou_dn}" -s base '(objectClass=organizationalUnit)' dn >/dev/null 2>&1; then + echo "OU ${ou_name} exists" + return 0 + fi + + echo "Creating OU ${ou_name}" + cat <