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

368 lines
16 KiB
YAML

# services/keycloak/ldap-federation-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: keycloak-ldap-federation-6
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
serviceAccountName: sso-vault
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: LDAP_URL
value: ldap://openldap.sso.svc.cluster.local:389
- name: LDAP_BIND_DN
value: cn=admin,dc=bstein,dc=dev
- 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
. /vault/scripts/keycloak_vault_env.sh
python - <<'PY'
import json
import os
import time
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"]
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()
# Keycloak component "parentId" must be the realm UUID, not the realm name.
status, realm_rep, _ = http_json(
"GET",
f"{base_url}/admin/realms/{realm}",
token,
)
if status != 200 or not realm_rep or not realm_rep.get("id"):
raise SystemExit(f"Unable to resolve realm id for {realm} (status={status})")
realm_id = realm_rep["id"]
# Some historical LDAP federation components were created with parentId=<realm name>.
# That makes realm resolution null in Keycloak internals and breaks authentication.
status, all_components, _ = http_json(
"GET",
f"{base_url}/admin/realms/{realm}/components",
token,
)
if status != 200:
raise SystemExit(f"Unexpected components response: {status}")
all_components = all_components or []
for c in all_components:
if c.get("providerId") != "ldap":
continue
if c.get("providerType") != "org.keycloak.storage.UserStorageProvider":
continue
if c.get("parentId") == realm_id:
continue
cid = c.get("id")
if not cid:
continue
print(f"Fixing LDAP federation parentId for {cid} (was {c.get('parentId')})")
status, comp, _ = http_json(
"GET",
f"{base_url}/admin/realms/{realm}/components/{cid}",
token,
)
if status != 200 or not comp:
raise SystemExit(f"Unable to fetch component {cid} (status={status})")
comp["parentId"] = realm_id
status, _, _ = http_json(
"PUT",
f"{base_url}/admin/realms/{realm}/components/{cid}",
token,
comp,
)
if status not in (200, 204):
raise SystemExit(f"Unexpected parentId repair status for {cid}: {status}")
# 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_components = [c for c in components if c.get("providerId") == "ldap" and c.get("id")]
# Select a canonical LDAP federation provider deterministically.
# Duplicate LDAP providers can cause Keycloak admin/user queries to fail if any one of them is misconfigured.
candidates = []
for c in ldap_components:
if c.get("name") not in ("openldap", "ldap"):
continue
cfg = c.get("config") or {}
if (cfg.get("connectionUrl") or [None])[0] == ldap_url:
candidates.append(c)
if not candidates:
candidates = [c for c in ldap_components if c.get("name") in ("openldap", "ldap")]
candidates.sort(key=lambda x: x.get("id", ""))
ldap_component = candidates[0] if candidates else None
ldap_component_id = ldap_component["id"] if ldap_component else None
desired = {
"name": "openldap",
"providerId": "ldap",
"providerType": "org.keycloak.storage.UserStorageProvider",
"parentId": realm_id,
"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}")
# Cleanup duplicate LDAP federation providers and their child components (mappers, etc).
# Keep only the canonical provider we updated/created above.
try:
status, fresh_components, _ = http_json(
"GET",
f"{base_url}/admin/realms/{realm}/components",
token,
)
if status != 200:
raise Exception(f"unexpected components status {status}")
fresh_components = fresh_components or []
dup_provider_ids = []
for c in fresh_components:
if c.get("providerId") != "ldap":
continue
if c.get("providerType") != "org.keycloak.storage.UserStorageProvider":
continue
cid = c.get("id")
if not cid or cid == ldap_component_id:
continue
dup_provider_ids.append(cid)
if dup_provider_ids:
for pid in dup_provider_ids:
# Delete child components first.
for child in fresh_components:
if child.get("parentId") != pid:
continue
child_id = child.get("id")
if not child_id:
continue
try:
http_json(
"DELETE",
f"{base_url}/admin/realms/{realm}/components/{child_id}",
token,
)
except urllib.error.HTTPError as e:
print(f"WARNING: failed to delete LDAP child component {child_id} (status={e.code})")
try:
http_json(
"DELETE",
f"{base_url}/admin/realms/{realm}/components/{pid}",
token,
)
except urllib.error.HTTPError as e:
print(f"WARNING: failed to delete duplicate LDAP provider {pid} (status={e.code})")
print(f"Cleaned up {len(dup_provider_ids)} duplicate LDAP federation providers")
except Exception as e:
print(f"WARNING: LDAP cleanup failed (continuing): {e}")
PY
volumeMounts:
- name: vault-secrets
mountPath: /vault/secrets
readOnly: true
- name: vault-scripts
mountPath: /vault/scripts
readOnly: true
volumes:
- name: vault-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: sso-vault
- name: vault-scripts
configMap:
name: sso-vault-env
defaultMode: 0555