Compare commits
2 Commits
2a1e3e2692
...
35a788865e
| Author | SHA1 | Date | |
|---|---|---|---|
| 35a788865e | |||
| f4bd03dc52 |
14
build.sh
14
build.sh
@@ -13,11 +13,15 @@ if [ -n "$REGISTRY" ]; then
|
|||||||
BACKEND_IMAGE_DATED="${REGISTRY}/mgmt-backend:${DATE_TAG}"
|
BACKEND_IMAGE_DATED="${REGISTRY}/mgmt-backend:${DATE_TAG}"
|
||||||
FRONTEND_IMAGE="${REGISTRY}/mgmt-frontend:${TAG}"
|
FRONTEND_IMAGE="${REGISTRY}/mgmt-frontend:${TAG}"
|
||||||
FRONTEND_IMAGE_DATED="${REGISTRY}/mgmt-frontend:${DATE_TAG}"
|
FRONTEND_IMAGE_DATED="${REGISTRY}/mgmt-frontend:${DATE_TAG}"
|
||||||
|
LDAP_GATEWAY_IMAGE="${REGISTRY}/mgmt-ldap-gateway:${TAG}"
|
||||||
|
LDAP_GATEWAY_IMAGE_DATED="${REGISTRY}/mgmt-ldap-gateway:${DATE_TAG}"
|
||||||
else
|
else
|
||||||
BACKEND_IMAGE="mgmt-backend:${TAG}"
|
BACKEND_IMAGE="mgmt-backend:${TAG}"
|
||||||
BACKEND_IMAGE_DATED="mgmt-backend:${DATE_TAG}"
|
BACKEND_IMAGE_DATED="mgmt-backend:${DATE_TAG}"
|
||||||
FRONTEND_IMAGE="mgmt-frontend:${TAG}"
|
FRONTEND_IMAGE="mgmt-frontend:${TAG}"
|
||||||
FRONTEND_IMAGE_DATED="mgmt-frontend:${DATE_TAG}"
|
FRONTEND_IMAGE_DATED="mgmt-frontend:${DATE_TAG}"
|
||||||
|
LDAP_GATEWAY_IMAGE="mgmt-ldap-gateway:${TAG}"
|
||||||
|
LDAP_GATEWAY_IMAGE_DATED="mgmt-ldap-gateway:${DATE_TAG}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building Management Suite containers..."
|
echo "Building Management Suite containers..."
|
||||||
@@ -25,6 +29,8 @@ echo " Backend: ${BACKEND_IMAGE}"
|
|||||||
echo " ${BACKEND_IMAGE_DATED}"
|
echo " ${BACKEND_IMAGE_DATED}"
|
||||||
echo " Frontend: ${FRONTEND_IMAGE}"
|
echo " Frontend: ${FRONTEND_IMAGE}"
|
||||||
echo " ${FRONTEND_IMAGE_DATED}"
|
echo " ${FRONTEND_IMAGE_DATED}"
|
||||||
|
echo " LDAP Gateway: ${LDAP_GATEWAY_IMAGE}"
|
||||||
|
echo " ${LDAP_GATEWAY_IMAGE_DATED}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "==> Staging user guide for backend image..."
|
echo "==> Staging user guide for backend image..."
|
||||||
@@ -44,12 +50,18 @@ echo "==> Building frontend..."
|
|||||||
podman build --target builder -t "mgmt-frontend-builder:${TAG}" "$SCRIPT_DIR/frontend"
|
podman build --target builder -t "mgmt-frontend-builder:${TAG}" "$SCRIPT_DIR/frontend"
|
||||||
podman build --build-arg BUILDER_IMAGE="mgmt-frontend-builder:${TAG}" -t "$FRONTEND_IMAGE" -t "$FRONTEND_IMAGE_DATED" "$SCRIPT_DIR/frontend"
|
podman build --build-arg BUILDER_IMAGE="mgmt-frontend-builder:${TAG}" -t "$FRONTEND_IMAGE" -t "$FRONTEND_IMAGE_DATED" "$SCRIPT_DIR/frontend"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> Building LDAP gateway..."
|
||||||
|
podman build -t "$LDAP_GATEWAY_IMAGE" -t "$LDAP_GATEWAY_IMAGE_DATED" "$SCRIPT_DIR/ldap-gateway"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Build complete!"
|
echo "Build complete!"
|
||||||
echo " ${BACKEND_IMAGE}"
|
echo " ${BACKEND_IMAGE}"
|
||||||
echo " ${BACKEND_IMAGE_DATED}"
|
echo " ${BACKEND_IMAGE_DATED}"
|
||||||
echo " ${FRONTEND_IMAGE}"
|
echo " ${FRONTEND_IMAGE}"
|
||||||
echo " ${FRONTEND_IMAGE_DATED}"
|
echo " ${FRONTEND_IMAGE_DATED}"
|
||||||
|
echo " ${LDAP_GATEWAY_IMAGE}"
|
||||||
|
echo " ${LDAP_GATEWAY_IMAGE_DATED}"
|
||||||
|
|
||||||
if [ -n "$REGISTRY" ]; then
|
if [ -n "$REGISTRY" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
@@ -59,6 +71,8 @@ if [ -n "$REGISTRY" ]; then
|
|||||||
podman push "$BACKEND_IMAGE_DATED"
|
podman push "$BACKEND_IMAGE_DATED"
|
||||||
podman push "$FRONTEND_IMAGE"
|
podman push "$FRONTEND_IMAGE"
|
||||||
podman push "$FRONTEND_IMAGE_DATED"
|
podman push "$FRONTEND_IMAGE_DATED"
|
||||||
|
podman push "$LDAP_GATEWAY_IMAGE"
|
||||||
|
podman push "$LDAP_GATEWAY_IMAGE_DATED"
|
||||||
echo "Pushed."
|
echo "Pushed."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ busybox:1.37
|
|||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "mgmt-suite.ldapGatewayName" -}}
|
||||||
|
{{ include "mgmt-suite.fullname" . }}-ldap-gateway
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "mgmt-suite.ldapGatewayImage" -}}
|
||||||
|
{{- if .Values.imageRegistry -}}
|
||||||
|
{{ .Values.imageRegistry }}/{{ .Values.ldapGateway.image }}:{{ .Values.ldapGateway.tag }}
|
||||||
|
{{- else -}}
|
||||||
|
{{ .Values.ldapGateway.image }}:{{ .Values.ldapGateway.tag }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
{{- define "mgmt-suite.postgresName" -}}
|
{{- define "mgmt-suite.postgresName" -}}
|
||||||
{{ include "mgmt-suite.fullname" . }}-postgres
|
{{ include "mgmt-suite.fullname" . }}-postgres
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|||||||
45
chart/mgmt-suite/templates/ldap-gateway-configmap.yaml
Normal file
45
chart/mgmt-suite/templates/ldap-gateway-configmap.yaml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{{- if .Values.ldapGateway.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "mgmt-suite.ldapGatewayName" . }}-config
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/component: ldap-gateway
|
||||||
|
{{- include "mgmt-suite.labels" . | nindent 4 }}
|
||||||
|
data:
|
||||||
|
# ── Keycloak (reuse the backend's KC conventions) ──────────
|
||||||
|
KEYCLOAK_URL: {{ .Values.keycloak.url | quote }}
|
||||||
|
KEYCLOAK_REALM: {{ .Values.keycloak.realm | default "mgmt" | quote }}
|
||||||
|
KEYCLOAK_CLIENT_ID: {{ .Values.keycloak.clientId | default "mgmt-admin-client" | quote }}
|
||||||
|
KEYCLOAK_VERIFY_SSL: {{ .Values.keycloak.verifySsl | quote }}
|
||||||
|
KEYCLOAK_TIMEOUT: {{ .Values.keycloak.timeout | default 30 | quote }}
|
||||||
|
# ── LDAP server ────────────────────────────────────────────
|
||||||
|
LDAP_BASE_DN: {{ required "ldapGateway.baseDn is required when ldapGateway.enabled" .Values.ldapGateway.baseDn | quote }}
|
||||||
|
LDAP_BIND_HOST: {{ .Values.ldapGateway.bindHost | default "0.0.0.0" | quote }}
|
||||||
|
LDAP_PORT: {{ .Values.ldapGateway.service.ldapPort | default 389 | quote }}
|
||||||
|
LDAPS_PORT: {{ .Values.ldapGateway.service.ldapsPort | default 636 | quote }}
|
||||||
|
LDAP_TLS_CERT: {{ .Values.ldapGateway.tls.certPath | default "/etc/ldap-gateway/tls/tls.crt" | quote }}
|
||||||
|
LDAP_TLS_KEY: {{ .Values.ldapGateway.tls.keyPath | default "/etc/ldap-gateway/tls/tls.key" | quote }}
|
||||||
|
LDAP_ALLOW_ANON: {{ .Values.ldapGateway.allowAnon | default false | quote }}
|
||||||
|
# ── Identity source (pluggable RDN / primary key) ──────────
|
||||||
|
LDAP_IDENTITY_SOURCE: {{ .Values.ldapGateway.identitySource | default "username" | quote }}
|
||||||
|
LDAP_CERT_CN_ATTRIBUTE: {{ .Values.ldapGateway.certCnAttribute | default "certCN" | quote }}
|
||||||
|
# ── POSIX UID/GID allocation ───────────────────────────────
|
||||||
|
LDAP_UID_MIN: {{ .Values.ldapGateway.posix.uidMin | default 100000 | quote }}
|
||||||
|
LDAP_UID_MAX: {{ .Values.ldapGateway.posix.uidMax | default 599999 | quote }}
|
||||||
|
LDAP_GID_MIN: {{ .Values.ldapGateway.posix.gidMin | default 100000 | quote }}
|
||||||
|
LDAP_GID_MAX: {{ .Values.ldapGateway.posix.gidMax | default 599999 | quote }}
|
||||||
|
LDAP_DEFAULT_SHELL: {{ .Values.ldapGateway.posix.defaultShell | default "/bin/bash" | quote }}
|
||||||
|
LDAP_HOME_BASE: {{ .Values.ldapGateway.posix.homeBase | default "/home" | quote }}
|
||||||
|
# ── Caching & filtering ────────────────────────────────────
|
||||||
|
LDAP_CACHE_TTL: {{ .Values.ldapGateway.cacheTtl | default 60 | quote }}
|
||||||
|
LDAP_REFRESH_INTERVAL: {{ .Values.ldapGateway.refreshInterval | default .Values.ldapGateway.cacheTtl | default 60 | quote }}
|
||||||
|
LDAP_EXCLUDE_DISABLED: {{ .Values.ldapGateway.excludeDisabled | default true | quote }}
|
||||||
|
LDAP_EXCLUDE_SERVICE_ACCOUNTS: {{ .Values.ldapGateway.excludeServiceAccounts | default true | quote }}
|
||||||
|
{{- if .Values.ldapGateway.groupInclude }}
|
||||||
|
LDAP_GROUP_INCLUDE: {{ .Values.ldapGateway.groupInclude | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ldapGateway.groupExclude }}
|
||||||
|
LDAP_GROUP_EXCLUDE: {{ .Values.ldapGateway.groupExclude | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
117
chart/mgmt-suite/templates/ldap-gateway-deployment.yaml
Normal file
117
chart/mgmt-suite/templates/ldap-gateway-deployment.yaml
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{{- if .Values.ldapGateway.enabled }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "mgmt-suite.ldapGatewayName" . }}
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/component: ldap-gateway
|
||||||
|
{{- include "mgmt-suite.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.ldapGateway.replicas }}
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/component: ldap-gateway
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/component: ldap-gateway
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.caCert.enabled }}
|
||||||
|
initContainers:
|
||||||
|
- name: bundle-ca-certs
|
||||||
|
image: {{ include "mgmt-suite.busyboxImage" . }}
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
# Concatenate all cert files, normalise line endings (strip \r),
|
||||||
|
# ensure each file ends with a newline, then remove blank lines.
|
||||||
|
for f in /certs/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
tr -d '\r' < "$f"
|
||||||
|
echo
|
||||||
|
done | sed '/^[[:space:]]*$/d' > /bundle/ca-bundle.crt
|
||||||
|
volumeMounts:
|
||||||
|
- name: ca-certs
|
||||||
|
mountPath: /certs
|
||||||
|
readOnly: true
|
||||||
|
- name: ca-bundle
|
||||||
|
mountPath: /bundle
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: ldap-gateway
|
||||||
|
image: {{ include "mgmt-suite.ldapGatewayImage" . }}
|
||||||
|
imagePullPolicy: {{ eq .Values.ldapGateway.tag "latest" | ternary "Always" "IfNotPresent" }}
|
||||||
|
ports:
|
||||||
|
- name: ldap
|
||||||
|
containerPort: {{ .Values.ldapGateway.service.ldapPort | default 389 }}
|
||||||
|
protocol: TCP
|
||||||
|
- name: ldaps
|
||||||
|
containerPort: {{ .Values.ldapGateway.service.ldapsPort | default 636 }}
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
{{- if .Values.caCert.enabled }}
|
||||||
|
- name: REQUESTS_CA_BUNDLE
|
||||||
|
value: /etc/ssl/certs/custom-ca-bundle/ca-bundle.crt
|
||||||
|
- name: SSL_CERT_FILE
|
||||||
|
value: /etc/ssl/certs/custom-ca-bundle/ca-bundle.crt
|
||||||
|
{{- end }}
|
||||||
|
# KEYCLOAK_CLIENT_SECRET reuses the SHARED secret the backend uses.
|
||||||
|
- name: KEYCLOAK_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.existingSecret | default (printf "%s-secrets" (include "mgmt-suite.fullname" .)) }}
|
||||||
|
key: KEYCLOAK_CLIENT_SECRET
|
||||||
|
# LDAP_SERVICE_ACCOUNTS (bind-DN→secret JSON map) from the dedicated LDAP secret.
|
||||||
|
- name: LDAP_SERVICE_ACCOUNTS
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ required "ldapGateway.serviceAccountsSecret.name is required when ldapGateway.enabled" .Values.ldapGateway.serviceAccountsSecret.name }}
|
||||||
|
key: {{ .Values.ldapGateway.serviceAccountsSecret.key | default "LDAP_SERVICE_ACCOUNTS" }}
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: {{ include "mgmt-suite.ldapGatewayName" . }}-config
|
||||||
|
volumeMounts:
|
||||||
|
- name: ldap-tls
|
||||||
|
mountPath: {{ .Values.ldapGateway.tls.mountPath | default "/etc/ldap-gateway/tls" }}
|
||||||
|
readOnly: true
|
||||||
|
{{- if .Values.caCert.enabled }}
|
||||||
|
- name: ca-bundle
|
||||||
|
mountPath: /etc/ssl/certs/custom-ca-bundle
|
||||||
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: {{ .Values.ldapGateway.service.ldapPort | default 389 }}
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: {{ .Values.ldapGateway.service.ldapPort | default 389 }}
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.ldapGateway.resources | nindent 12 }}
|
||||||
|
volumes:
|
||||||
|
- name: ldap-tls
|
||||||
|
secret:
|
||||||
|
secretName: {{ required "ldapGateway.tls.secretName is required when ldapGateway.enabled" .Values.ldapGateway.tls.secretName }}
|
||||||
|
{{- if .Values.caCert.enabled }}
|
||||||
|
- name: ca-certs
|
||||||
|
secret:
|
||||||
|
secretName: {{ .Values.caCert.secretName }}
|
||||||
|
- name: ca-bundle
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
27
chart/mgmt-suite/templates/ldap-gateway-service.yaml
Normal file
27
chart/mgmt-suite/templates/ldap-gateway-service.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{- if .Values.ldapGateway.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "mgmt-suite.ldapGatewayName" . }}
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/component: ldap-gateway
|
||||||
|
{{- include "mgmt-suite.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ldapGateway.service.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.ldapGateway.service.type | default "ClusterIP" }}
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/component: ldap-gateway
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
ports:
|
||||||
|
- name: ldap
|
||||||
|
port: {{ .Values.ldapGateway.service.ldapPort | default 389 }}
|
||||||
|
targetPort: ldap
|
||||||
|
protocol: TCP
|
||||||
|
- name: ldaps
|
||||||
|
port: {{ .Values.ldapGateway.service.ldapsPort | default 636 }}
|
||||||
|
targetPort: ldaps
|
||||||
|
protocol: TCP
|
||||||
|
{{- end }}
|
||||||
@@ -194,3 +194,62 @@ ingress:
|
|||||||
# AWS ALB annotation "alb.ingress.kubernetes.io/certificate-arn" is present.
|
# AWS ALB annotation "alb.ingress.kubernetes.io/certificate-arn" is present.
|
||||||
externalTls: false
|
externalTls: false
|
||||||
annotations: {}
|
annotations: {}
|
||||||
|
|
||||||
|
# ── LDAP Gateway ─────────────────────────────────────────────
|
||||||
|
# Standalone microservice that exposes Keycloak users/groups over LDAP v3
|
||||||
|
# (read-only, service-bind only) for legacy apps and SSSD/NSS hosts.
|
||||||
|
# Stateless and horizontally scalable — no DB/queue. Each pod refreshes its
|
||||||
|
# own cache, so keep replicas modest or raise refreshInterval.
|
||||||
|
ldapGateway:
|
||||||
|
enabled: false
|
||||||
|
image: mgmt-ldap-gateway
|
||||||
|
tag: latest
|
||||||
|
replicas: 1
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
service:
|
||||||
|
type: ClusterIP # ClusterIP for in-cluster consumers; LoadBalancer/NodePort for external LDAP clients
|
||||||
|
ldapPort: 389 # plain / StartTLS
|
||||||
|
ldapsPort: 636 # LDAPS
|
||||||
|
annotations: {}
|
||||||
|
bindHost: "0.0.0.0"
|
||||||
|
allowAnon: false
|
||||||
|
# Base DN for the directory tree, e.g. "dc=mgmt,dc=example,dc=com"
|
||||||
|
baseDn: ""
|
||||||
|
# Pluggable identity / user RDN source: "username" (default) or "cert_cn" (future, stubbed)
|
||||||
|
identitySource: "username"
|
||||||
|
# KC user attribute read when identitySource is "cert_cn"
|
||||||
|
certCnAttribute: "certCN"
|
||||||
|
# POSIX UID/GID allocation (KC custom attr wins, else derived from the stable KC UUID)
|
||||||
|
posix:
|
||||||
|
uidMin: 100000
|
||||||
|
uidMax: 599999
|
||||||
|
gidMin: 100000
|
||||||
|
gidMax: 599999
|
||||||
|
defaultShell: "/bin/bash"
|
||||||
|
homeBase: "/home"
|
||||||
|
# Caching & freshness (seconds)
|
||||||
|
cacheTtl: 60
|
||||||
|
refreshInterval: 60 # defaults to cacheTtl when unset
|
||||||
|
excludeDisabled: true
|
||||||
|
excludeServiceAccounts: true
|
||||||
|
groupInclude: "" # optional comma/space-separated name globs; empty = all
|
||||||
|
groupExclude: "" # optional name globs to exclude
|
||||||
|
# TLS material (cert/key) mounted from an existing Secret.
|
||||||
|
# Follow the chart's external-secret pattern: provide a Secret by name
|
||||||
|
# (e.g. from ExternalSecrets/Sealed Secrets/cert-manager) — Helm does not generate it.
|
||||||
|
tls:
|
||||||
|
secretName: "" # Secret containing tls.crt and tls.key
|
||||||
|
mountPath: "/etc/ldap-gateway/tls"
|
||||||
|
certPath: "/etc/ldap-gateway/tls/tls.crt"
|
||||||
|
keyPath: "/etc/ldap-gateway/tls/tls.key"
|
||||||
|
# Service-bind accounts (bind-DN→password JSON map) from an existing Secret.
|
||||||
|
# Provide the Secret by name (external-secret pattern); Helm does not generate it.
|
||||||
|
serviceAccountsSecret:
|
||||||
|
name: "" # Secret containing the LDAP_SERVICE_ACCOUNTS key
|
||||||
|
key: "LDAP_SERVICE_ACCOUNTS"
|
||||||
|
|||||||
252
docs/ldap-gateway.md
Normal file
252
docs/ldap-gateway.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# LDAP Gateway — Install & Configuration
|
||||||
|
|
||||||
|
The **LDAP Gateway** is a standalone microservice (Twisted + [ldaptor](https://ldaptor.readthedocs.io/))
|
||||||
|
that exposes Keycloak users and groups over **LDAP v3**, so legacy apps and Linux
|
||||||
|
hosts (SSSD/NSS) that only speak LDAP can consume identity data sourced from Keycloak.
|
||||||
|
|
||||||
|
It is **read-only** and **stateless**: it reads directly from the Keycloak Admin API
|
||||||
|
(with an in-memory TTL cache), holds no database, and shares nothing with the main
|
||||||
|
app except the Keycloak connection and the deployment Helm chart.
|
||||||
|
|
||||||
|
> Design rationale and the full architecture live in
|
||||||
|
> [`ldap-gateway/SPEC.md`](../ldap-gateway/SPEC.md); the binding module contract is
|
||||||
|
> [`ldap-gateway/CONTRACT.md`](../ldap-gateway/CONTRACT.md). This document is the
|
||||||
|
> operator-facing install/config guide.
|
||||||
|
|
||||||
|
## What it does (and doesn't)
|
||||||
|
|
||||||
|
- ✅ Serves `inetOrgPerson` + `posixAccount`/`posixGroup` entries (uid/gid/home,
|
||||||
|
`memberOf`, group `memberUid`/`member`).
|
||||||
|
- ✅ Authenticates LDAP **bind only for configured service accounts**.
|
||||||
|
- ❌ Does **not** verify end-user passwords via LDAP bind. It is an identity/directory
|
||||||
|
source, not an authentication backend. (Hosts must authenticate by another means —
|
||||||
|
Kerberos, OIDC/PAM, etc.)
|
||||||
|
- ❌ Does **not** accept writes — `ADD`/`MODIFY`/`DELETE` return `unwillingToPerform`.
|
||||||
|
Keycloak (via the main app's queue) stays the only write path.
|
||||||
|
|
||||||
|
Changes in Keycloak propagate within the cache TTL (default 60s).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **A reachable Keycloak** with the realm you want to expose.
|
||||||
|
2. **A service-account client** with permission to read users and groups via the Admin
|
||||||
|
API. The Management Suite already provisions `mgmt-admin-client` (realm-admin role) —
|
||||||
|
the gateway reuses the same client ID + secret. See
|
||||||
|
[`keycloak-setup.md`](keycloak-setup.md) for how that client is created.
|
||||||
|
3. **At least one LDAP service-bind account** — a bind DN + password the gateway will
|
||||||
|
accept. This is *gateway-local* (not a Keycloak user).
|
||||||
|
4. *(Production)* **TLS material** (cert + key). Bind credentials travel in cleartext
|
||||||
|
over plain LDAP — run TLS-only in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
With `./dev.sh` running (Keycloak on `http://localhost:8180`, secret written to
|
||||||
|
`.keycloak-secret`), the helper script wires everything up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ldap-gateway/run-local.sh # foreground; Ctrl-C to stop
|
||||||
|
```
|
||||||
|
|
||||||
|
It binds `ldap://127.0.0.1:1389` (non-privileged port) with one demo service account
|
||||||
|
`cn=svc,ou=services,dc=mgmt,dc=example,dc=com` / `svcpass`. Override any value via env
|
||||||
|
before launching (see the reference below).
|
||||||
|
|
||||||
|
Verify with `ldapsearch`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Look up one user
|
||||||
|
ldapsearch -x -H ldap://127.0.0.1:1389 \
|
||||||
|
-D "cn=svc,ou=services,dc=mgmt,dc=example,dc=com" -w svcpass \
|
||||||
|
-b "ou=people,dc=mgmt,dc=example,dc=com" "(uid=alice)"
|
||||||
|
|
||||||
|
# List a group's members
|
||||||
|
ldapsearch -x -H ldap://127.0.0.1:1389 \
|
||||||
|
-D "cn=svc,ou=services,dc=mgmt,dc=example,dc=com" -w svcpass \
|
||||||
|
-b "ou=groups,dc=mgmt,dc=example,dc=com" "(cn=ACME)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ldap-gateway
|
||||||
|
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
||||||
|
.venv/bin/pytest # 125 tests
|
||||||
|
.venv/bin/ruff check . && .venv/bin/ruff format --check .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building the image
|
||||||
|
|
||||||
|
The gateway is built by the top-level build script alongside backend/frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh # builds mgmt-backend, mgmt-frontend, mgmt-ldap-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces `mgmt-ldap-gateway:latest` (and a dated tag), registry-prefixed when
|
||||||
|
`REGISTRY` is set, and offers to push. To build just the gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman build -t mgmt-ldap-gateway:latest ./ldap-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
The image runs as non-root (uid 10001) and starts `python -m ldap_gateway.server`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration reference
|
||||||
|
|
||||||
|
All configuration is via environment variables. Required values raise a clear error at
|
||||||
|
startup if missing.
|
||||||
|
|
||||||
|
### Keycloak connection
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `KEYCLOAK_URL` | — *(required)* | Base URL, e.g. `https://keycloak.example.com` |
|
||||||
|
| `KEYCLOAK_REALM` | `mgmt` | Realm to expose |
|
||||||
|
| `KEYCLOAK_CLIENT_ID` | `mgmt-admin-client` | Service-account client with Admin read access |
|
||||||
|
| `KEYCLOAK_CLIENT_SECRET` | — | Client secret |
|
||||||
|
| `KEYCLOAK_VERIFY_SSL` | `true` | Set `false` for self-signed/local |
|
||||||
|
| `KEYCLOAK_TIMEOUT` | `30` | Per-request timeout (seconds) |
|
||||||
|
|
||||||
|
### LDAP server
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `LDAP_BASE_DN` | — *(required)* | e.g. `dc=mgmt,dc=example,dc=com` |
|
||||||
|
| `LDAP_BIND_HOST` | `0.0.0.0` | Listen interface |
|
||||||
|
| `LDAP_PORT` | `389` | Plain / StartTLS port |
|
||||||
|
| `LDAP_TLS_CERT` / `LDAP_TLS_KEY` | unset | Paths to cert/key; when **both** set, LDAPS is enabled |
|
||||||
|
| *(LDAPS port)* | `636` | Used only when TLS cert+key are set |
|
||||||
|
| `LDAP_ALLOW_ANON` | `false` | Allow anonymous bind (read). Keep `false`. |
|
||||||
|
| `LDAP_SERVICE_ACCOUNTS` | `{}` | JSON map of bind DN → password, e.g. `{"cn=svc,ou=services,dc=mgmt,dc=example,dc=com":"s3cret"}` |
|
||||||
|
|
||||||
|
### Identity (user RDN / primary key)
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `LDAP_IDENTITY_SOURCE` | `username` | `username` → `uid=<username>`; `cert_cn` → reads a KC attribute (stub, see below) |
|
||||||
|
| `LDAP_CERT_CN_ATTRIBUTE` | `certCN` | KC user attribute read when `identity_source=cert_cn` |
|
||||||
|
|
||||||
|
### POSIX
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `LDAP_UID_MIN` / `LDAP_UID_MAX` | `100000` / `599999` | UID allocation range |
|
||||||
|
| `LDAP_GID_MIN` / `LDAP_GID_MAX` | `100000` / `599999` | GID allocation range |
|
||||||
|
| `LDAP_DEFAULT_SHELL` | `/bin/bash` | `loginShell` |
|
||||||
|
| `LDAP_HOME_BASE` | `/home` | `homeDirectory` = `<home_base>/<username>` |
|
||||||
|
|
||||||
|
> UID/GID come from a KC custom attribute (`uidNumber`/`gidNumber`) when present,
|
||||||
|
> otherwise they are derived deterministically from the **stable KC UUID**, so they
|
||||||
|
> survive renames. Pin them as KC attributes if you need authoritative, conflict-free
|
||||||
|
> numbers.
|
||||||
|
|
||||||
|
### Caching & filtering
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `LDAP_CACHE_TTL` | `60` | Snapshot freshness (seconds) |
|
||||||
|
| `LDAP_REFRESH_INTERVAL` | = `LDAP_CACHE_TTL` | Background refresh interval |
|
||||||
|
| `LDAP_EXCLUDE_DISABLED` | `true` | Skip disabled KC users |
|
||||||
|
| `LDAP_EXCLUDE_SERVICE_ACCOUNTS` | `true` | Skip `service-account-*` / `*.svc` users |
|
||||||
|
| `LDAP_GROUP_INCLUDE` | empty (all) | Comma/space-separated name globs to include |
|
||||||
|
| `LDAP_GROUP_EXCLUDE` | empty | Name globs to exclude |
|
||||||
|
| `LOG_LEVEL` | `INFO` | Standard logging level |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Swapping the identity source (cert CN)
|
||||||
|
|
||||||
|
The user RDN is not hardcoded. Today it is the Keycloak username (`uid=`). When the
|
||||||
|
cert-CN attribute becomes the primary identity key, switch with config only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LDAP_IDENTITY_SOURCE=cert_cn
|
||||||
|
LDAP_CERT_CN_ATTRIBUTE=certCN # whatever KC attribute holds the cert CN
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cert_cn` resolver is currently a **stub**: it reads the named attribute if present
|
||||||
|
and **skips (logs) users that lack it** rather than crashing. Finalize the resolver in
|
||||||
|
`ldap_gateway/identity.py` once the attribute name and RDN convention are confirmed.
|
||||||
|
Regardless of source, `entryUUID` (the stable KC UUID) is always served as the durable
|
||||||
|
key — prefer it over DN for clients that pin to an identifier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploying with Helm
|
||||||
|
|
||||||
|
The gateway ships in the `mgmt-suite` chart, **disabled by default**. Enable it and
|
||||||
|
supply the required values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# values.yaml (or --set)
|
||||||
|
ldapGateway:
|
||||||
|
enabled: true
|
||||||
|
baseDn: "dc=mgmt,dc=example,dc=com"
|
||||||
|
service:
|
||||||
|
type: ClusterIP # LoadBalancer/NodePort for clients outside the cluster
|
||||||
|
tls:
|
||||||
|
secretName: ldap-gateway-tls # Secret with tls.crt / tls.key
|
||||||
|
serviceAccountsSecret:
|
||||||
|
name: ldap-gateway-service-accounts # Secret with the LDAP_SERVICE_ACCOUNTS key
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Keycloak secret is shared** — the deployment injects `KEYCLOAK_CLIENT_SECRET` from
|
||||||
|
the same Secret the backend uses; no duplication.
|
||||||
|
- **TLS and service-bind Secrets are referenced by name, not generated** (consistent
|
||||||
|
with the chart's external-secret pattern). Provide them via ExternalSecrets / Sealed
|
||||||
|
Secrets / cert-manager. Both `baseDn`, `tls.secretName`, and
|
||||||
|
`serviceAccountsSecret.name` are required when enabled and fail template rendering if
|
||||||
|
missing.
|
||||||
|
- All other tunables (`identitySource`, `certCnAttribute`, POSIX ranges, `cacheTtl`,
|
||||||
|
`refreshInterval`, exclude flags, group filters) live under the `ldapGateway:` block
|
||||||
|
in [`chart/mgmt-suite/values.yaml`](../chart/mgmt-suite/values.yaml).
|
||||||
|
|
||||||
|
Render and verify before applying:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm template mgmt ./chart/mgmt-suite \
|
||||||
|
--set secrets.fernetKey=... --set secrets.secretKey=... \
|
||||||
|
--set ldapGateway.enabled=true \
|
||||||
|
--set ldapGateway.baseDn='dc=mgmt,dc=example,dc=com' \
|
||||||
|
--set ldapGateway.tls.secretName=ldap-gateway-tls \
|
||||||
|
--set ldapGateway.serviceAccountsSecret.name=ldap-gateway-service-accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Privileged ports
|
||||||
|
|
||||||
|
The container runs as non-root and the Service exposes 389/636. Binding ports < 1024 as
|
||||||
|
non-root requires the `NET_BIND_SERVICE` capability (or a `securityContext`) depending
|
||||||
|
on the runtime. If the pod fails to bind, either add the capability in the deployment's
|
||||||
|
`securityContext` or map high container ports behind the Service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- **Scaling** — fully stateless; scale replicas freely. Each pod refreshes its own
|
||||||
|
cache, so more pods mean proportionally more Admin API load per refresh interval —
|
||||||
|
keep replica count modest or lengthen `LDAP_REFRESH_INTERVAL`.
|
||||||
|
- **Freshness** — searches reflect Keycloak as of the last successful refresh (≤ TTL).
|
||||||
|
On a Keycloak outage the gateway keeps serving the last good snapshot.
|
||||||
|
- **Health** — readiness should gate on the first successful cache warm; liveness is a
|
||||||
|
TCP check on the LDAP port (the Helm deployment configures TCP probes).
|
||||||
|
- **DN stability** — DNs are keyed on the identity RDN (username today), so a rename
|
||||||
|
moves the DN. Clients that need a durable key should use `entryUUID`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause |
|
||||||
|
|---|---|
|
||||||
|
| `ldap_bind: Invalid credentials (49)` | Anonymous bind (disabled), unknown bind DN, or wrong service-account password |
|
||||||
|
| `Server is unwilling to perform (53)` on write | Expected — the gateway is read-only |
|
||||||
|
| Empty results / `0 entries` | Cache not warmed (check logs for KC connection errors), or users filtered out by `LDAP_EXCLUDE_*` / group filters |
|
||||||
|
| Startup error about missing var | `KEYCLOAK_URL` or `LDAP_BASE_DN` not set |
|
||||||
|
| A user is missing | Disabled in KC, a service account, or (with `cert_cn`) lacks the cert-CN attribute — all skipped by design; check logs |
|
||||||
10
ldap-gateway/.dockerignore
Normal file
10
ldap-gateway/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
tests/
|
||||||
|
run-local.sh
|
||||||
|
*.md
|
||||||
|
.git/
|
||||||
260
ldap-gateway/CONTRACT.md
Normal file
260
ldap-gateway/CONTRACT.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# LDAP Gateway — Interface Contract (BINDING)
|
||||||
|
|
||||||
|
Every module is implemented to the signatures below so the parts compose. **Do not deviate**
|
||||||
|
from these names/shapes — if something seems wrong, implement to the contract anyway and leave a
|
||||||
|
`# CONTRACT-NOTE:` comment explaining the concern. Read `SPEC.md` for the design rationale.
|
||||||
|
|
||||||
|
- Python **3.11+**, package name **`ldap_gateway`** (under `ldap-gateway/`).
|
||||||
|
- Runtime deps: `twisted`, `ldaptor`, `requests`, `pyopenssl`, `service_identity`.
|
||||||
|
- Style: type hints everywhere, stdlib `logging` (module logger `logging.getLogger(__name__)`),
|
||||||
|
no print(). Generic error messages to clients; details logged server-side.
|
||||||
|
- KC user/group objects are **plain dicts** in KC Admin REST shape (e.g. user has
|
||||||
|
`"id"`, `"username"`, `"firstName"`, `"lastName"`, `"email"`, `"enabled"`,
|
||||||
|
`"attributes": {name: [values]}`; group has `"id"`, `"name"`, `"attributes"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## config.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
# Keycloak
|
||||||
|
keycloak_url: str
|
||||||
|
keycloak_realm: str
|
||||||
|
keycloak_client_id: str
|
||||||
|
keycloak_client_secret: str
|
||||||
|
keycloak_verify_ssl: bool
|
||||||
|
keycloak_timeout: int
|
||||||
|
# LDAP server
|
||||||
|
base_dn: str # e.g. "dc=mgmt,dc=example,dc=com"
|
||||||
|
bind_host: str
|
||||||
|
port: int # plain / StartTLS, default 389
|
||||||
|
ldaps_port: int # LDAPS, default 636
|
||||||
|
tls_cert: str | None # path
|
||||||
|
tls_key: str | None # path
|
||||||
|
allow_anon: bool
|
||||||
|
service_accounts: dict[str, str] # bind DN (lowercased) -> password
|
||||||
|
# Identity (see identity.py)
|
||||||
|
identity_source: str # "username" | "cert_cn"
|
||||||
|
cert_cn_attribute: str # KC user attribute name for cert CN, default "certCN"
|
||||||
|
# POSIX
|
||||||
|
uid_min: int
|
||||||
|
uid_max: int
|
||||||
|
gid_min: int
|
||||||
|
gid_max: int
|
||||||
|
default_shell: str # default "/bin/bash"
|
||||||
|
home_base: str # default "/home"
|
||||||
|
# Caching / filtering
|
||||||
|
cache_ttl: int # seconds, default 60
|
||||||
|
refresh_interval: int # seconds, default = cache_ttl
|
||||||
|
exclude_disabled: bool # default True
|
||||||
|
exclude_service_accounts: bool # default True
|
||||||
|
group_include: list[str] # name globs, empty = all
|
||||||
|
group_exclude: list[str] # name globs
|
||||||
|
|
||||||
|
def load_config(env=None) -> Config: ...
|
||||||
|
# env defaults to os.environ. Parse the LDAP_* / KEYCLOAK_* vars from SPEC.md §11.
|
||||||
|
# service_accounts parsed from LDAP_SERVICE_ACCOUNTS as JSON {"dn": "password", ...}; keys lowercased.
|
||||||
|
# Raise ValueError with a clear message on missing required vars (keycloak_url, base_dn).
|
||||||
|
```
|
||||||
|
|
||||||
|
## identity.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Protocol
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
class IdentityResolver(Protocol):
|
||||||
|
@property
|
||||||
|
def rdn_attr(self) -> str: ... # LDAP attr used as the user RDN, e.g. "uid"
|
||||||
|
def resolve(self, kc_user: dict) -> str | None: ...
|
||||||
|
# RDN value for this user, or None to SKIP the user from the directory.
|
||||||
|
|
||||||
|
class UsernameIdentityResolver:
|
||||||
|
# rdn_attr == "uid"; resolve -> kc_user.get("username") or None
|
||||||
|
...
|
||||||
|
|
||||||
|
class CertCNIdentityResolver:
|
||||||
|
# __init__(self, cert_cn_attribute: str); rdn_attr == "cn"
|
||||||
|
# resolve -> first value of kc_user["attributes"][cert_cn_attribute], else None
|
||||||
|
# STUB: details TBD. resolve() may read the attribute if present; if the attribute is absent
|
||||||
|
# return None (skip + log). Do NOT crash the server. Mark clearly as a stub.
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_identity_resolver(config: Config) -> IdentityResolver: ...
|
||||||
|
# "username" -> UsernameIdentityResolver(); "cert_cn" -> CertCNIdentityResolver(config.cert_cn_attribute)
|
||||||
|
# unknown -> ValueError.
|
||||||
|
```
|
||||||
|
|
||||||
|
## kc_client.py (synchronous; cache calls it via deferToThread)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
class KeycloakError(Exception): ...
|
||||||
|
|
||||||
|
class KeycloakClient:
|
||||||
|
def __init__(self, config: Config): ...
|
||||||
|
# Token via OAuth2 client_credentials against
|
||||||
|
# {url}/realms/{realm}/protocol/openid-connect/token (cached, refreshed with a buffer).
|
||||||
|
# Admin REST base: {url}/admin/realms/{realm}
|
||||||
|
def get_all_users(self) -> list[dict]: ...
|
||||||
|
# Paginated walk of /users (first/max). Returns full user dicts incl. "attributes".
|
||||||
|
def get_all_groups(self) -> list[dict]: ...
|
||||||
|
# Flattened list of top-level groups AND all subgroups (recurse /groups + group.subGroups
|
||||||
|
# or /groups/{id}/children). Each dict MUST include "id", "name", "attributes".
|
||||||
|
def get_group_members(self, group_id: str) -> list[dict]: ...
|
||||||
|
# /groups/{id}/members, paginated. Returns user dicts.
|
||||||
|
# On HTTP/connection error raise KeycloakError (callers handle / keep last good snapshot).
|
||||||
|
```
|
||||||
|
|
||||||
|
## cache.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from .config import Config
|
||||||
|
from .kc_client import KeycloakClient
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DirectorySnapshot:
|
||||||
|
users: list[dict]
|
||||||
|
groups: list[dict]
|
||||||
|
members_by_group: dict[str, list[dict]] # group_id -> member user dicts
|
||||||
|
|
||||||
|
class KeycloakCache:
|
||||||
|
def __init__(self, client: KeycloakClient, config: Config): ...
|
||||||
|
def refresh(self) -> DirectorySnapshot: ...
|
||||||
|
# BLOCKING: fetch users, groups, and members per group; build + store snapshot; return it.
|
||||||
|
# Single-flight (a lock so concurrent refreshes coalesce). On KeycloakError, log and
|
||||||
|
# return the last good snapshot if one exists, else re-raise.
|
||||||
|
def current(self) -> DirectorySnapshot | None: ...
|
||||||
|
# Last successfully fetched snapshot, or None if never refreshed. Non-blocking, thread-safe.
|
||||||
|
```
|
||||||
|
|
||||||
|
## mapping.py (pure functions — no Twisted, no I/O)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .config import Config
|
||||||
|
from .identity import IdentityResolver
|
||||||
|
|
||||||
|
def user_dn(rdn_value: str, config: Config, resolver: IdentityResolver) -> str: ...
|
||||||
|
# f"{resolver.rdn_attr}={escape(rdn_value)},ou=people,{config.base_dn}" (RFC 4514 escaping)
|
||||||
|
def group_dn(group_name: str, config: Config) -> str: ...
|
||||||
|
# f"cn={escape(group_name)},ou=groups,{config.base_dn}"
|
||||||
|
def people_ou_dn(config) -> str # f"ou=people,{config.base_dn}"
|
||||||
|
def groups_ou_dn(config) -> str # f"ou=groups,{config.base_dn}"
|
||||||
|
def services_ou_dn(config) -> str # f"ou=services,{config.base_dn}"
|
||||||
|
|
||||||
|
def uid_number(kc_user: dict, config: Config) -> int: ...
|
||||||
|
# KC attribute "uidNumber" if present+int, else deterministic: config.uid_min +
|
||||||
|
# int(sha1(kc_user["id"]).hexdigest(),16) % (config.uid_max - config.uid_min). Keyed on the
|
||||||
|
# stable UUID. (Collision linear-probe optional; document if skipped.)
|
||||||
|
def gid_number(kc_group_or_user: dict, config: Config) -> int: ...
|
||||||
|
# Same scheme over gid range; "gidNumber" attribute wins.
|
||||||
|
|
||||||
|
def should_include_user(kc_user: dict, config: Config) -> bool: ...
|
||||||
|
# False if exclude_disabled and not enabled; False if exclude_service_accounts and username
|
||||||
|
# startswith "service-account-" or endswith ".svc".
|
||||||
|
def should_include_group(kc_group: dict, config: Config) -> bool: ...
|
||||||
|
# Apply group_include (if non-empty, name must match one glob) then group_exclude.
|
||||||
|
|
||||||
|
def user_entry_attributes(kc_user: dict, rdn_value: str, member_group_dns: list[str],
|
||||||
|
config: Config, resolver: IdentityResolver) -> dict[str, list[str]]: ...
|
||||||
|
# objectClass top/person/organizationalPerson/inetOrgPerson/posixAccount/shadowAccount;
|
||||||
|
# resolver.rdn_attr, uid (=username), cn, sn, givenName, mail, displayName, entryUUID (=KC id),
|
||||||
|
# uidNumber, gidNumber, homeDirectory ({home_base}/{username}), loginShell, memberOf.
|
||||||
|
# NEVER include userPassword. Values are lists[str]. Omit attrs whose source is missing.
|
||||||
|
def group_entry_attributes(kc_group: dict, member_rdn_values: list[str], member_dns: list[str],
|
||||||
|
config: Config) -> dict[str, list[str]]: ...
|
||||||
|
# objectClass top/posixGroup (+ groupOfNames only when member_dns non-empty);
|
||||||
|
# cn, gidNumber, memberUid (member RDN values), member (DNs, only if non-empty).
|
||||||
|
```
|
||||||
|
|
||||||
|
## directory.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ldaptor.inmemory import ReadOnlyInMemoryLDAPEntry # or appropriate ldaptor import
|
||||||
|
from .cache import DirectorySnapshot
|
||||||
|
from .config import Config
|
||||||
|
from .identity import IdentityResolver
|
||||||
|
|
||||||
|
def build_tree(snapshot: DirectorySnapshot, config: Config,
|
||||||
|
resolver: IdentityResolver) -> ReadOnlyInMemoryLDAPEntry: ...
|
||||||
|
# Root = base_dn (dcObject/organization). Children ou=people, ou=groups, ou=services.
|
||||||
|
# For each user where should_include_user AND resolver.resolve(user) is not None:
|
||||||
|
# compute rdn_value, member group DNs, add child via mapping.user_entry_attributes.
|
||||||
|
# For each group where should_include_group: add child via mapping.group_entry_attributes,
|
||||||
|
# resolving member rdn values/DNs from the user set (skip members not in the directory).
|
||||||
|
# Add service-account entries under ou=services from config.service_accounts keys.
|
||||||
|
|
||||||
|
class DirectoryHolder:
|
||||||
|
def __init__(self, config: Config, resolver: IdentityResolver): ...
|
||||||
|
# tree starts as an empty-but-valid root (base_dn + empty OUs) so searches before the
|
||||||
|
# first refresh succeed with zero results.
|
||||||
|
def swap(self, snapshot: DirectorySnapshot) -> None: ... # atomically rebuild + replace tree
|
||||||
|
@property
|
||||||
|
def tree(self): ... # current IConnectedLDAPEntry root
|
||||||
|
```
|
||||||
|
|
||||||
|
## bind.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
def validate_service_bind(dn: str, password: bytes, config: Config) -> bool: ...
|
||||||
|
# Look up dn (lowercased) in config.service_accounts; constant-time compare (hmac.compare_digest)
|
||||||
|
# against the configured password. False if dn unknown or password mismatch.
|
||||||
|
```
|
||||||
|
|
||||||
|
## handlers.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ldaptor.protocols.ldap.ldapserver import LDAPServer
|
||||||
|
from twisted.internet.protocol import ServerFactory
|
||||||
|
from .directory import DirectoryHolder
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
class GatewayLDAPServer(LDAPServer):
|
||||||
|
# self.factory is LDAPServerFactory (has .holder and .config).
|
||||||
|
# Override the root-entry hook so searches resolve against self.factory.holder.tree
|
||||||
|
# (ldaptor: implement/override `getRootDSE`/`handle_LDAPBindRequest` as needed; the tree is
|
||||||
|
# provided by setting self.factory.root = holder.tree-equivalent — see ldaptor LDAPServer).
|
||||||
|
# Bind: anonymous (empty DN+password) allowed only if config.allow_anon; service DNs validated
|
||||||
|
# via bind.validate_service_bind; everything else -> invalidCredentials (LDAPException code 49).
|
||||||
|
# Writes: override handle_LDAPAddRequest / Modify / Del / ModifyDN to return unwillingToPerform (53).
|
||||||
|
|
||||||
|
class LDAPServerFactory(ServerFactory):
|
||||||
|
protocol = GatewayLDAPServer
|
||||||
|
def __init__(self, holder: DirectoryHolder, config: Config): ...
|
||||||
|
def buildProtocol(self, addr): ...
|
||||||
|
# proto = self.protocol(); proto.factory = self; (wire root to holder.tree); return proto
|
||||||
|
```
|
||||||
|
|
||||||
|
> ldaptor note: `LDAPServer` resolves searches against `self.factory.root` (an `IConnectedLDAPEntry`).
|
||||||
|
> Point that at the holder's current tree at bind/search time so refreshes are picked up live.
|
||||||
|
|
||||||
|
## server.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
def main() -> None: ...
|
||||||
|
# load_config(); get_identity_resolver(); KeycloakClient; KeycloakCache; DirectoryHolder.
|
||||||
|
# Initial warm: snapshot = cache.refresh(); holder.swap(snapshot) (log + continue on failure).
|
||||||
|
# factory = LDAPServerFactory(holder, config).
|
||||||
|
# reactor.listenTCP(port, factory) for StartTLS; reactor.listenSSL(ldaps_port, factory, ctx)
|
||||||
|
# when tls_cert/tls_key set (ssl.DefaultOpenSSLContextFactory).
|
||||||
|
# LoopingCall(refresh interval): deferToThread(cache.refresh).addCallbacks(holder.swap, log_err).
|
||||||
|
# reactor.run().
|
||||||
|
|
||||||
|
if __name__ == "__main__": main()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `pytest`; tests under `ldap-gateway/tests/`. Pure-logic modules (mapping, identity, bind,
|
||||||
|
config) get direct unit tests. kc_client tests mock `requests`. cache tests use a fake client.
|
||||||
|
directory tests build a tree from a synthetic snapshot and assert search results. handlers tests
|
||||||
|
may use ldaptor test helpers / a fake factory.
|
||||||
|
- No network in tests. Keep them fast and deterministic.
|
||||||
27
ldap-gateway/Dockerfile
Normal file
27
ldap-gateway/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Read-only LDAP gateway over Keycloak.
|
||||||
|
# Build context: the ldap-gateway/ directory (this file's directory).
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Avoid .pyc clutter and force unbuffered stdout/stderr so logs stream to the
|
||||||
|
# container runtime promptly.
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install the package (pyproject.toml drives dependency resolution). Copying the
|
||||||
|
# whole context keeps things simple; the package is small.
|
||||||
|
COPY . /app
|
||||||
|
RUN pip install --no-cache-dir . \
|
||||||
|
&& rm -rf /root/.cache/pip
|
||||||
|
|
||||||
|
# Run as an unprivileged user. Ports 389/636 are privileged on the host but the
|
||||||
|
# container's own network namespace allows binding them as non-root in most
|
||||||
|
# orchestrators; if a runtime forbids it, map to high ports via the Service.
|
||||||
|
RUN useradd --create-home --uid 10001 ldap
|
||||||
|
USER ldap
|
||||||
|
|
||||||
|
# LDAP (plain/StartTLS) and LDAPS.
|
||||||
|
EXPOSE 389 636
|
||||||
|
|
||||||
|
CMD ["python", "-m", "ldap_gateway.server"]
|
||||||
133
ldap-gateway/README.md
Normal file
133
ldap-gateway/README.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# LDAP Gateway
|
||||||
|
|
||||||
|
A standalone, **read-only** LDAP v3 service that exposes Keycloak users and
|
||||||
|
groups over LDAP/LDAPS so legacy applications and Linux hosts (SSSD/NSS) that
|
||||||
|
can only speak LDAP can consume identity data sourced from Keycloak.
|
||||||
|
|
||||||
|
Built with Python + [Twisted](https://twistedmatrix.com/) +
|
||||||
|
[ldaptor](https://ldaptor.readthedocs.io/). It reads directly from the Keycloak
|
||||||
|
Admin API (cached in memory), rebuilds an in-memory directory tree on each
|
||||||
|
refresh, and serves searches against it. No database, no queue — fully
|
||||||
|
stateless and horizontally scalable.
|
||||||
|
|
||||||
|
See [`SPEC.md`](SPEC.md) for the full design and [`CONTRACT.md`](CONTRACT.md)
|
||||||
|
for the module interfaces.
|
||||||
|
|
||||||
|
## Design decisions (SPEC §1)
|
||||||
|
|
||||||
|
1. **Read source — direct Keycloak Admin API (cached).** Always-fresh-ish data
|
||||||
|
with no extra datastore; the service is stateless. KC load is mitigated by an
|
||||||
|
in-memory TTL cache plus a background refresh loop.
|
||||||
|
2. **Bind / auth — service-bind, read-only.** Only configured service accounts
|
||||||
|
may bind. The gateway does **not** verify end-user passwords — it is an
|
||||||
|
identity/directory source, not an auth backend.
|
||||||
|
3. **Write scope — read-only.** All `ADD`/`MODIFY`/`DELETE`/`MODRDN` return
|
||||||
|
`unwillingToPerform`. Keycloak (via the main app) remains the only write path.
|
||||||
|
4. **Schema — POSIX + inetOrgPerson.** Users are served as
|
||||||
|
`inetOrgPerson` + `posixAccount`/`shadowAccount` and groups as `posixGroup`
|
||||||
|
(+ `groupOfNames` when non-empty), enabling SSSD/NSS identity lookups
|
||||||
|
(uid/gid/home/shell) on Linux hosts.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- No password authentication via LDAP bind (use OIDC; KC-delegated bind is a
|
||||||
|
deferred phase).
|
||||||
|
- Read-only; directory changes happen in Keycloak and propagate within the
|
||||||
|
cache TTL (eventual consistency).
|
||||||
|
- POSIX uid/gid numbers are derived from the stable KC UUID (or pinned via a KC
|
||||||
|
custom attribute), not authoritative allocations.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is via environment variables. The full list and defaults are
|
||||||
|
documented in [`SPEC.md` §11](SPEC.md). Key variables:
|
||||||
|
|
||||||
|
| Var | Purpose | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `KEYCLOAK_URL` | Keycloak base URL (**required**) | — |
|
||||||
|
| `KEYCLOAK_REALM` | Realm | `mgmt` |
|
||||||
|
| `KEYCLOAK_CLIENT_ID` / `KEYCLOAK_CLIENT_SECRET` | `client_credentials` service account | — |
|
||||||
|
| `LDAP_BASE_DN` | Base DN, e.g. `dc=mgmt,dc=example,dc=com` (**required**) | — |
|
||||||
|
| `LDAP_BIND_HOST` | Listen interface | `0.0.0.0` |
|
||||||
|
| `LDAP_PORT` / `LDAPS_PORT` | Plain/StartTLS and LDAPS ports | `389` / `636` |
|
||||||
|
| `LDAP_TLS_CERT` / `LDAP_TLS_KEY` | Cert/key paths; both enable LDAPS | unset |
|
||||||
|
| `LDAP_ALLOW_ANON` | Allow anonymous bind | `false` |
|
||||||
|
| `LDAP_SERVICE_ACCOUNTS` | JSON map of bind-DN -> password | `{}` |
|
||||||
|
| `LDAP_CACHE_TTL` / `LDAP_REFRESH_INTERVAL` | Cache TTL / background refresh | `60` / `= TTL` |
|
||||||
|
| `LDAP_IDENTITY_SOURCE` | `username` or `cert_cn` (see below) | `username` |
|
||||||
|
| `LOG_LEVEL` | Root log level | `INFO` |
|
||||||
|
|
||||||
|
POSIX (`LDAP_UID_MIN/MAX`, `LDAP_GID_MIN/MAX`, `LDAP_DEFAULT_SHELL`,
|
||||||
|
`LDAP_HOME_BASE`) and filtering (`LDAP_EXCLUDE_DISABLED`,
|
||||||
|
`LDAP_EXCLUDE_SERVICE_ACCOUNTS`, `LDAP_GROUP_INCLUDE/_EXCLUDE`) vars are listed
|
||||||
|
in `SPEC.md` §11.
|
||||||
|
|
||||||
|
### Swappable identity (`LDAP_IDENTITY_SOURCE`)
|
||||||
|
|
||||||
|
The user entry's RDN attribute and the value pulled from Keycloak come from a
|
||||||
|
pluggable `IdentityResolver`, so the primary identity key can change without
|
||||||
|
touching mapping/directory code:
|
||||||
|
|
||||||
|
- `username` *(default)* — `uid=<KC username>`.
|
||||||
|
- `cert_cn` *(future, stubbed)* — reads the KC user attribute named by
|
||||||
|
`LDAP_CERT_CN_ATTRIBUTE` (default `certCN`) as `cn=<value>`. Users missing the
|
||||||
|
attribute are skipped (and logged).
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
You need a reachable Keycloak with a `client_credentials`-capable service
|
||||||
|
account that can read users and groups.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
export KEYCLOAK_URL=https://keycloak.example.com
|
||||||
|
export KEYCLOAK_REALM=mgmt
|
||||||
|
export KEYCLOAK_CLIENT_ID=ldap-gateway
|
||||||
|
export KEYCLOAK_CLIENT_SECRET=...
|
||||||
|
export LDAP_BASE_DN=dc=mgmt,dc=example,dc=com
|
||||||
|
export LDAP_SERVICE_ACCOUNTS='{"cn=svc,ou=services,dc=mgmt,dc=example,dc=com":"s3cret"}'
|
||||||
|
|
||||||
|
python -m ldap_gateway.server # or: python -m ldap_gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Then query it (the gateway answers searches; it does not validate user
|
||||||
|
passwords):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapsearch -x -H ldap://localhost:389 \
|
||||||
|
-D "cn=svc,ou=services,dc=mgmt,dc=example,dc=com" -w s3cret \
|
||||||
|
-b "ou=people,dc=mgmt,dc=example,dc=com" "(objectClass=posixAccount)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Binding to ports 389/636 may require elevated privileges locally; set
|
||||||
|
`LDAP_PORT`/`LDAPS_PORT` to high ports (e.g. `3389`/`6636`) during development.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .[dev]
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests are network-free and never start the reactor.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
The build context is this directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t ldap-gateway .
|
||||||
|
|
||||||
|
docker run --rm -p 3389:389 -p 6636:636 \
|
||||||
|
-e KEYCLOAK_URL=https://keycloak.example.com \
|
||||||
|
-e KEYCLOAK_CLIENT_ID=ldap-gateway \
|
||||||
|
-e KEYCLOAK_CLIENT_SECRET=... \
|
||||||
|
-e LDAP_BASE_DN=dc=mgmt,dc=example,dc=com \
|
||||||
|
-e LDAP_SERVICE_ACCOUNTS='{"cn=svc,ou=services,dc=mgmt,dc=example,dc=com":"s3cret"}' \
|
||||||
|
ldap-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
For TLS, mount the cert/key and set `LDAP_TLS_CERT`/`LDAP_TLS_KEY`. The image
|
||||||
|
runs as a non-root user; map privileged ports to high host/Service ports as
|
||||||
|
needed.
|
||||||
310
ldap-gateway/SPEC.md
Normal file
310
ldap-gateway/SPEC.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# LDAP Gateway — Specification
|
||||||
|
|
||||||
|
A standalone microservice that exposes Keycloak users and groups over **LDAP v3**, so
|
||||||
|
legacy applications and Linux hosts (SSSD/NSS) that can only speak LDAP can consume
|
||||||
|
identity data sourced from Keycloak.
|
||||||
|
|
||||||
|
Built with **Python + Twisted + [ldaptor](https://ldaptor.readthedocs.io/)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope & decisions
|
||||||
|
|
||||||
|
This spec reflects four design decisions taken up front:
|
||||||
|
|
||||||
|
| Decision | Choice | Consequence |
|
||||||
|
|---|---|---|
|
||||||
|
| **Read source** | Direct KC Admin API (cached) | Always-fresh-ish data, no extra datastore, fully stateless service. Adds load to KC — mitigated by an in-memory TTL cache + background refresh. |
|
||||||
|
| **Bind / auth** | Service-bind read-only | Only configured service accounts may bind. **The gateway does NOT verify end-user passwords.** It is a directory/identity source, not an auth backend. |
|
||||||
|
| **Write scope** | Read-only | All `ADD`/`MODIFY`/`DELETE`/`MODRDN` return `unwillingToPerform`. KC (via the main app's queue) remains the only write path. |
|
||||||
|
| **Schema** | POSIX + inetOrgPerson | Full directory: `inetOrgPerson` + `posixAccount`/`posixGroup`, enabling SSSD/NSS identity lookups (uid/gid/home) on Linux hosts. |
|
||||||
|
|
||||||
|
### Explicit non-goals / limitations (state these to consumers)
|
||||||
|
1. **No password authentication via LDAP bind.** Apps needing "validate user password by binding" must use OIDC, or wait for the deferred KC-delegated-bind phase (§11).
|
||||||
|
2. **Read-only.** Directory changes happen in KC/the main app and propagate within the cache TTL.
|
||||||
|
3. **Eventual consistency.** Up to `LDAP_CACHE_TTL` of staleness (comparable to the app's existing ~30s inbound sync).
|
||||||
|
4. **POSIX numbers are derived** (or pinned via a KC custom attribute), not authoritative allocations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Why ldaptor / Twisted
|
||||||
|
|
||||||
|
- `ldaptor` implements the LDAP v3 protocol, DN/entry/filter abstractions, and schema.
|
||||||
|
- Twisted provides async networking + TLS (LDAPS / StartTLS).
|
||||||
|
- We do **not** use ldaptor's file-backed tree as the source of truth. Instead, on each
|
||||||
|
cache refresh we **rebuild an in-memory `ReadOnlyInMemoryLDAPEntry` tree** from KC data.
|
||||||
|
ldaptor then handles search scoping/filters/paging against that tree for free — far less
|
||||||
|
code than a bespoke search engine, and naturally read-only.
|
||||||
|
- Tradeoff: the whole tree is rebuilt each refresh. Fine for thousands of users; see §10 scale notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────── ldap-gateway pod (stateless) ────────────────────────┐
|
||||||
|
│ │
|
||||||
|
LDAP/LDAPS │ Twisted reactor │
|
||||||
|
clients ──►│ ┌───────────────┐ search ┌──────────────────┐ read ┌──────────┐ │
|
||||||
|
│ │ LDAPServer │────────────►│ in-memory tree │◄───────────│ Cache │ │
|
||||||
|
│ │ (handlers.py) │ bind(svc) │ (ReadOnly...) │ rebuild │ (TTL + │ │
|
||||||
|
│ └───────────────┘ └──────────────────┘ │ refresh) │ │
|
||||||
|
│ │ service-bind only └────┬─────┘ │
|
||||||
|
│ ▼ │ fetch │
|
||||||
|
│ ┌───────────────┐ ┌────────▼─────┐ │
|
||||||
|
│ │ bind.py │ │ kc_client.py │ │
|
||||||
|
│ │ (svc creds) │ │ (Admin API) │ │
|
||||||
|
│ └───────────────┘ └────────┬─────┘ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────┼─────┘
|
||||||
|
▼
|
||||||
|
Keycloak Admin REST API
|
||||||
|
(client_credentials grant)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`kc_client.py`** — self-contained async wrapper over the KC Admin API (no dependency on the
|
||||||
|
main app's code — only the same KC endpoint + service-account credentials). Token via
|
||||||
|
`client_credentials` grant, cached with refresh buffer. Needs just
|
||||||
|
`get_all_users`, `get_all_groups`, `get_group_members`, `get_user_groups`. Either `treq` for
|
||||||
|
native-async, or sync `requests` wrapped with `deferToThread`.
|
||||||
|
- **`cache.py`** — TTL cache with a `LoopingCall` background refresh that pre-warms data so
|
||||||
|
LDAP query latency is decoupled from KC latency. Single-flight refresh (stampede guard) and
|
||||||
|
a negative cache for not-found lookups.
|
||||||
|
- **`directory.py`** — builds/holds the `ReadOnlyInMemoryLDAPEntry` tree from cached KC data;
|
||||||
|
swapped atomically on each refresh.
|
||||||
|
- **`mapping.py`** — KC→LDAP entry mapping, DN⇄username/group, UID/GID allocation.
|
||||||
|
- **`bind.py`** — validates service-account binds (constant-time compare).
|
||||||
|
- **`handlers.py`** — `LDAPServer` subclass: permit search + service bind; writes →
|
||||||
|
`unwillingToPerform`; anonymous bind denied by default.
|
||||||
|
- **`server.py`** — reactor, TLS endpoints, refresh loop, metrics endpoint wiring.
|
||||||
|
|
||||||
|
**Statelessness is a key payoff of the direct-read choice:** no DB, no queue, no leader
|
||||||
|
election. Pods scale horizontally; each caches independently. (Multi-pod multiplies KC refresh
|
||||||
|
load — see §10.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Directory Information Tree (DIT)
|
||||||
|
|
||||||
|
Base DN configurable via `LDAP_BASE_DN`, e.g. `dc=mgmt,dc=example,dc=com`.
|
||||||
|
|
||||||
|
```
|
||||||
|
dc=mgmt,dc=example,dc=com (dcObject, organization)
|
||||||
|
├── ou=people,dc=mgmt,dc=example,dc=com (organizationalUnit)
|
||||||
|
│ └── uid=<username>,ou=people,… (inetOrgPerson + posixAccount + shadowAccount)
|
||||||
|
├── ou=groups,dc=mgmt,dc=example,dc=com (organizationalUnit)
|
||||||
|
│ └── cn=<groupname>,ou=groups,… (posixGroup [+ groupOfNames when non-empty])
|
||||||
|
└── ou=services,dc=mgmt,dc=example,dc=com (service bind accounts; creds from secret)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Attribute mapping
|
||||||
|
|
||||||
|
### 5.1 User (KC user → entry under `ou=people`)
|
||||||
|
| LDAP attr | Source | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `dn` | `<rdn_attr>=<identity>,ou=people,<base>` | RDN attribute + value come from the pluggable **`IdentityResolver`** (§5.3). Default today: `uid=<username>`. |
|
||||||
|
| `objectClass` | static | `top, person, organizationalPerson, inetOrgPerson, posixAccount, shadowAccount` |
|
||||||
|
| `uid` | KC `username` | |
|
||||||
|
| `cn` | `firstName lastName` (fallback `username`) | |
|
||||||
|
| `sn` | KC `lastName` | |
|
||||||
|
| `givenName` | KC `firstName` | |
|
||||||
|
| `mail` | KC `email` | |
|
||||||
|
| `displayName` | full name | |
|
||||||
|
| `entryUUID` | KC user `id` (UUID) | **Stable identifier** — recommend clients key on this, not DN. |
|
||||||
|
| `uidNumber` | KC custom attr `uidNumber` if present, else derived | See §6. |
|
||||||
|
| `gidNumber` | primary group number | See §6. |
|
||||||
|
| `homeDirectory` | `${LDAP_HOME_BASE}/<username>` | default `/home/<username>` |
|
||||||
|
| `loginShell` | `LDAP_DEFAULT_SHELL` | default `/bin/bash` |
|
||||||
|
| `memberOf` | computed from KC group memberships | operational attr; many clients rely on it |
|
||||||
|
| `userPassword` | **not exposed** | gateway does no password bind; never serve credential material |
|
||||||
|
|
||||||
|
### 5.2 Group (KC group → entry under `ou=groups`)
|
||||||
|
| LDAP attr | Source | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `dn` | `cn=<groupname>,ou=groups,<base>` | `cn` = exact KC group name, RFC 4514-escaped. |
|
||||||
|
| `objectClass` | static | `top, posixGroup` (+ `groupOfNames` only when ≥1 member — see §9 empty groups). |
|
||||||
|
| `cn` | KC group name | Includes the `{PROJECT}-{app}-{level}` convention names verbatim. |
|
||||||
|
| `gidNumber` | KC custom attr or derived | §6 |
|
||||||
|
| `memberUid` | usernames of members | posixGroup membership |
|
||||||
|
| `member` | member DNs | only when group is non-empty |
|
||||||
|
|
||||||
|
**Hierarchy flattening:** KC's top-level + child groups are flattened into one `ou=groups`
|
||||||
|
level, each as its own `cn` entry (e.g. both `ACME` and `ACME-bitbucket-read`). Membership is
|
||||||
|
**direct per group** (matching how each KC group's members are returned), not recursively rolled up.
|
||||||
|
|
||||||
|
### 5.3 Pluggable identity (the user RDN / primary key)
|
||||||
|
|
||||||
|
The attribute used as the user entry's RDN and the value pulled from KC are **not hardcoded** —
|
||||||
|
they come from an `IdentityResolver` selected by config, so the primary identity key can be
|
||||||
|
swapped without touching mapping/directory code:
|
||||||
|
|
||||||
|
- `LDAP_IDENTITY_SOURCE=username` *(default)* → `UsernameIdentityResolver`: `rdn_attr="uid"`,
|
||||||
|
value = KC `username`.
|
||||||
|
- `LDAP_IDENTITY_SOURCE=cert_cn` *(future, stubbed)* → `CertCNIdentityResolver`: reads a KC user
|
||||||
|
attribute named by `LDAP_CERT_CN_ATTRIBUTE` (default `certCN`) as the value. Exact KC attribute
|
||||||
|
name + RDN attribute are TBD — the stub raises a clear "not yet configured" error until details
|
||||||
|
land, but the wiring (config flag, factory, mapping call sites) is all in place.
|
||||||
|
|
||||||
|
A user whose resolver returns `None` (e.g. missing the cert CN attribute) is **skipped** from the
|
||||||
|
directory, and the skip is logged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. POSIX UID/GID allocation
|
||||||
|
|
||||||
|
KC does not store `uidNumber`/`gidNumber` natively. Strategy (precedence order):
|
||||||
|
|
||||||
|
1. **KC custom attribute** — if the user/group carries a `uidNumber`/`gidNumber` attribute in
|
||||||
|
KC, use it verbatim. Lets ops pin authoritative numbers.
|
||||||
|
2. **Deterministic derivation (fallback)** — hash the **stable KC UUID** into the configured
|
||||||
|
range: `uidNumber = LDAP_UID_MIN + (sha1(uuid) mod (LDAP_UID_MAX - LDAP_UID_MIN))`.
|
||||||
|
- Keyed on UUID (not username) so the number is stable across renames.
|
||||||
|
- Collision handling: log + linear-probe within range; range should be large
|
||||||
|
(default `100000–599999`) to make collisions vanishingly rare.
|
||||||
|
|
||||||
|
Configurable: `LDAP_UID_MIN/MAX`, `LDAP_GID_MIN/MAX`.
|
||||||
|
|
||||||
|
> Note for SSSD/NSS login: this gateway supplies **identity** (uid/gid/home/shell). Because it
|
||||||
|
> does not do password bind, hosts must authenticate users by another means (Kerberos, or
|
||||||
|
> KC via PAM/OIDC). Document this in host enrollment runbooks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Caching & freshness
|
||||||
|
|
||||||
|
- In-memory TTL cache (`LDAP_CACHE_TTL`, default 60s) for the user list, group list, and
|
||||||
|
memberships; the tree is rebuilt from it.
|
||||||
|
- Background `LoopingCall` refresh every `LDAP_REFRESH_INTERVAL` (default = TTL) so live queries
|
||||||
|
hit warm data; the new tree is swapped in atomically.
|
||||||
|
- Single-flight refresh (stampede guard) and negative cache for not-found.
|
||||||
|
- Per-pod KC load ≈ one full user enumeration + one group enumeration + memberships per refresh
|
||||||
|
interval. KC `get_all_users` is paged; the refresh walks all pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security
|
||||||
|
|
||||||
|
- **Service bind accounts** — configured as bind-DN → secret pairs (from a mounted Secret / env),
|
||||||
|
validated with constant-time comparison. DNs live under `ou=services`.
|
||||||
|
- **Anonymous bind** — denied by default (`LDAP_ALLOW_ANON=false`).
|
||||||
|
- **TLS** — LDAPS on 636 and/or StartTLS on 389; cert/key mounted from a Secret.
|
||||||
|
**Strongly recommend TLS-only in production** — bind creds are otherwise cleartext.
|
||||||
|
- **No credential exposure** — `userPassword` and any KC secrets are never served, even to bound
|
||||||
|
service accounts. Everything is read-only.
|
||||||
|
- Generic error responses to clients; details logged server-side (matches the main app's posture).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Edge cases & rules
|
||||||
|
|
||||||
|
- **Rename churn** — DN keyed on `uid` changes when KC username changes. Mitigate by serving the
|
||||||
|
stable `entryUUID`; document that DN is not a durable key.
|
||||||
|
- **Empty groups** — `groupOfNames` requires ≥1 `member`. Primary objectClass is `posixGroup`
|
||||||
|
(allows empty `memberUid`); add `groupOfNames`+`member` only when the group has members.
|
||||||
|
- **Service-account KC users** (`service-account-*` / `*.svc`) — excluded from `ou=people` by
|
||||||
|
default (configurable).
|
||||||
|
- **Disabled KC users** — excluded by default (configurable); optionally surfaced with
|
||||||
|
`shadowExpire`/account-lock semantics if ever needed.
|
||||||
|
- **Sponsor custom attribute** — ignored by default; optionally mapped to a custom LDAP attribute.
|
||||||
|
- **DN escaping** — RFC 4514 escaping for group/user names containing special characters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Deployment (Helm)
|
||||||
|
|
||||||
|
Add to `chart/mgmt-suite/templates/` (or a small subchart):
|
||||||
|
- `ldap-gateway-deployment.yaml` — image, env, mounted TLS + service-creds secret.
|
||||||
|
- `ldap-gateway-service.yaml` — exposes 389/636. `ClusterIP` for in-cluster consumers;
|
||||||
|
`LoadBalancer`/`NodePort` if external LDAP clients need it.
|
||||||
|
- `ldap-gateway-configmap.yaml` — non-sensitive `LDAP_*` config.
|
||||||
|
- Reuse the existing Secret for `KEYCLOAK_CLIENT_SECRET`; add an LDAP secret for service-bind
|
||||||
|
creds + TLS material.
|
||||||
|
- **Stateless & horizontally scalable** — no DB/queue/leader election (unlike the backend's KC
|
||||||
|
worker). Each pod refreshes its own cache; more pods = more KC refresh load, so keep replica
|
||||||
|
count modest or raise the refresh interval.
|
||||||
|
- **Health/readiness** — TCP check on the LDAP port; readiness gated on the first successful KC
|
||||||
|
cache warm.
|
||||||
|
- **Metrics** (optional) — small Twisted web endpoint exposing Prometheus counters: bind count,
|
||||||
|
search count, KC fetch latency/errors, cache hit ratio, entry counts. Wire a `ServiceMonitor`
|
||||||
|
to match the existing pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Configuration (env vars)
|
||||||
|
|
||||||
|
Reuse the backend's KC conventions where possible:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Keycloak (reuse existing names)
|
||||||
|
KEYCLOAK_URL
|
||||||
|
KEYCLOAK_REALM default: mgmt
|
||||||
|
KEYCLOAK_CLIENT_ID service-account client (read-only scope is sufficient)
|
||||||
|
KEYCLOAK_CLIENT_SECRET
|
||||||
|
KEYCLOAK_VERIFY_SSL default: true
|
||||||
|
KEYCLOAK_TIMEOUT default: 30
|
||||||
|
|
||||||
|
# LDAP server
|
||||||
|
LDAP_BASE_DN e.g. dc=mgmt,dc=example,dc=com
|
||||||
|
LDAP_BIND_HOST default: 0.0.0.0
|
||||||
|
LDAP_PORT default: 389 (StartTLS)
|
||||||
|
LDAPS_PORT default: 636 (LDAPS)
|
||||||
|
LDAP_TLS_CERT / LDAP_TLS_KEY paths to mounted cert/key
|
||||||
|
LDAP_ALLOW_ANON default: false
|
||||||
|
LDAP_SERVICE_ACCOUNTS bind-DN→secret map (from Secret/JSON)
|
||||||
|
|
||||||
|
# POSIX
|
||||||
|
LDAP_UID_MIN / LDAP_UID_MAX default: 100000 / 599999
|
||||||
|
LDAP_GID_MIN / LDAP_GID_MAX default: 100000 / 599999
|
||||||
|
LDAP_DEFAULT_SHELL default: /bin/bash
|
||||||
|
LDAP_HOME_BASE default: /home
|
||||||
|
|
||||||
|
# Caching & filtering
|
||||||
|
LDAP_CACHE_TTL seconds, default: 60
|
||||||
|
LDAP_REFRESH_INTERVAL seconds, default: = TTL
|
||||||
|
LDAP_EXCLUDE_DISABLED default: true
|
||||||
|
LDAP_EXCLUDE_SERVICE_ACCOUNTS default: true
|
||||||
|
LDAP_GROUP_INCLUDE / _EXCLUDE optional name patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
ldap-gateway/
|
||||||
|
pyproject.toml # ldaptor, twisted, treq, service_identity, pyopenssl, prometheus_client
|
||||||
|
ldap_gateway/
|
||||||
|
__init__.py
|
||||||
|
config.py # env parsing (mirror backend/config.py style)
|
||||||
|
kc_client.py # KC Admin API client (token + users/groups/memberships)
|
||||||
|
cache.py # TTL cache, background refresh, stampede guard
|
||||||
|
directory.py # build/hold the ReadOnlyInMemoryLDAPEntry tree
|
||||||
|
mapping.py # KC→LDAP mapping, DN<->name, UID/GID allocation
|
||||||
|
bind.py # service-account bind validation
|
||||||
|
handlers.py # LDAPServer subclass: search + svc bind; writes -> unwillingToPerform
|
||||||
|
server.py # reactor, TLS endpoints, LoopingCall refresh, metrics
|
||||||
|
tests/
|
||||||
|
test_mapping.py # attribute mapping, UID/GID determinism + collisions
|
||||||
|
test_directory_search.py # filters/scopes against a synthetic KC dataset
|
||||||
|
test_bind.py # service bind allowed; user/anon denied; writes rejected
|
||||||
|
test_cache.py # TTL, refresh swap, negative cache, single-flight
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
|
SPEC.md # this file
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Phased delivery
|
||||||
|
|
||||||
|
- **Phase 1 (MVP)** — `kc_client` + cache + read-only search tree (inetOrgPerson +
|
||||||
|
posixAccount/posixGroup), service bind, TLS, Dockerfile, Helm templates.
|
||||||
|
- **Phase 2** — metrics/observability, negative cache, empty-group handling, KC custom
|
||||||
|
uid/gid attribute support, group include/exclude filters.
|
||||||
|
- **Phase 3 (deferred — declined options, easy to bolt on later)**
|
||||||
|
- **KC-delegated user bind** via Resource Owner Password Credentials, enabling real LDAP
|
||||||
|
password authentication for legacy apps.
|
||||||
|
- **Read-write** by translating `MODIFY`/`ADD` into enqueued KC operations on the existing
|
||||||
|
Postgres queue + worker.
|
||||||
|
```
|
||||||
3
ldap-gateway/ldap_gateway/__init__.py
Normal file
3
ldap-gateway/ldap_gateway/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""ldap_gateway — LDAP v3 gateway exposing Keycloak identity data (read-only)."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
8
ldap-gateway/ldap_gateway/__main__.py
Normal file
8
ldap-gateway/ldap_gateway/__main__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Allow ``python -m ldap_gateway`` to start the gateway."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .server import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
57
ldap-gateway/ldap_gateway/bind.py
Normal file
57
ldap-gateway/ldap_gateway/bind.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Service-account bind validation for the LDAP gateway.
|
||||||
|
|
||||||
|
The gateway only authenticates configured *service accounts* (see ``SPEC.md`` §1, §8).
|
||||||
|
It never verifies end-user passwords — it is an identity/directory source, not an auth
|
||||||
|
backend. ``validate_service_bind`` performs a constant-time comparison of the supplied
|
||||||
|
bind credentials against the configured service-account secrets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Dummy secret used to keep the comparison time roughly constant even when the bind DN
|
||||||
|
# is unknown, so an attacker cannot distinguish "unknown DN" from "wrong password" by
|
||||||
|
# timing the response.
|
||||||
|
_DUMMY_SECRET = b"\x00" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def validate_service_bind(dn: str, password: bytes, config: Config) -> bool:
|
||||||
|
"""Return ``True`` iff ``dn``/``password`` match a configured service account.
|
||||||
|
|
||||||
|
``dn`` is lowercased and looked up in ``config.service_accounts`` (whose keys are
|
||||||
|
already lowercased at config-load time). The password is compared with
|
||||||
|
:func:`hmac.compare_digest` (constant-time). ``config`` stores passwords as ``str``;
|
||||||
|
the ``password`` argument arrives as ``bytes`` from the LDAP bind PDU — both are
|
||||||
|
normalised to ``bytes`` (UTF-8) before comparison.
|
||||||
|
|
||||||
|
Returns ``False`` for an unknown DN or a password mismatch. To avoid leaking, via
|
||||||
|
timing, whether the DN exists, an unknown DN still performs a comparison against a
|
||||||
|
fixed dummy secret.
|
||||||
|
"""
|
||||||
|
if not isinstance(password, (bytes, bytearray)):
|
||||||
|
# Defensive: the contract says bytes, but normalise rather than crash.
|
||||||
|
password = str(password).encode("utf-8")
|
||||||
|
password_bytes = bytes(password)
|
||||||
|
|
||||||
|
lookup_dn = dn.lower()
|
||||||
|
configured = config.service_accounts.get(lookup_dn)
|
||||||
|
|
||||||
|
if configured is None:
|
||||||
|
# Unknown DN: still compare to avoid a timing side-channel, then fail.
|
||||||
|
hmac.compare_digest(password_bytes, _DUMMY_SECRET)
|
||||||
|
logger.info("Service bind rejected: unknown bind DN")
|
||||||
|
return False
|
||||||
|
|
||||||
|
expected = configured.encode("utf-8")
|
||||||
|
if hmac.compare_digest(password_bytes, expected):
|
||||||
|
logger.info("Service bind accepted for DN %r", lookup_dn)
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Service bind rejected: password mismatch for DN %r", lookup_dn)
|
||||||
|
return False
|
||||||
155
ldap-gateway/ldap_gateway/cache.py
Normal file
155
ldap-gateway/ldap_gateway/cache.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""In-memory snapshot cache of Keycloak directory data.
|
||||||
|
|
||||||
|
Holds the last successfully fetched view of Keycloak users, groups, and group
|
||||||
|
memberships as an immutable :class:`DirectorySnapshot`. The directory tree
|
||||||
|
(:mod:`ldap_gateway.directory`) is rebuilt from a snapshot on each refresh.
|
||||||
|
|
||||||
|
See ``CONTRACT.md`` ("cache.py") for the binding interface and ``SPEC.md`` §7
|
||||||
|
for the caching/freshness rationale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .kc_client import KeycloakClient, KeycloakError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DirectorySnapshot:
|
||||||
|
"""An immutable point-in-time view of the Keycloak directory.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
users: Full KC user dicts (KC Admin REST shape, incl. ``attributes``).
|
||||||
|
groups: Flattened KC group dicts (top-level + subgroups), each incl.
|
||||||
|
``id``, ``name``, ``attributes``.
|
||||||
|
members_by_group: Maps a group ``id`` to the list of member user dicts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
users: list[dict]
|
||||||
|
groups: list[dict]
|
||||||
|
members_by_group: dict[str, list[dict]]
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakCache:
|
||||||
|
"""TTL/last-good cache over a :class:`KeycloakClient`.
|
||||||
|
|
||||||
|
Concurrency model
|
||||||
|
-----------------
|
||||||
|
A single :class:`threading.Lock` serializes the slow KC fetch *and* guards
|
||||||
|
access to the stored snapshot.
|
||||||
|
|
||||||
|
Single-flight is implemented with a coalescing window keyed on a monotonic
|
||||||
|
"generation" counter:
|
||||||
|
|
||||||
|
* Each caller records the generation it observed *before* acquiring the
|
||||||
|
lock.
|
||||||
|
* The first caller into the critical section performs the full fetch and
|
||||||
|
bumps the generation.
|
||||||
|
* A waiter that entered the lock *after* a fetch already completed (i.e.
|
||||||
|
the generation advanced while it was blocked) skips its own fetch and
|
||||||
|
returns the snapshot the first caller just produced, rather than
|
||||||
|
re-hammering Keycloak.
|
||||||
|
|
||||||
|
This keeps the implementation a single lock (simple, no condition-variable
|
||||||
|
bookkeeping) while guaranteeing that a burst of concurrent ``refresh()``
|
||||||
|
calls results in exactly one KC enumeration. Callers that arrive *after*
|
||||||
|
the coalescing window legitimately trigger a fresh fetch — that is the
|
||||||
|
intended TTL/background-refresh behaviour, not a stampede.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: KeycloakClient, config: Config) -> None:
|
||||||
|
self._client = client
|
||||||
|
self._config = config
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._snapshot: DirectorySnapshot | None = None
|
||||||
|
# Monotonic counter bumped on every successful fetch. Used to detect
|
||||||
|
# that another caller refreshed while we were waiting on the lock.
|
||||||
|
self._generation = 0
|
||||||
|
|
||||||
|
def refresh(self) -> DirectorySnapshot:
|
||||||
|
"""Fetch the directory from Keycloak and store it as the last-good snapshot.
|
||||||
|
|
||||||
|
BLOCKING. Enumerates all users, all groups, and the members of each
|
||||||
|
group, assembling a fresh :class:`DirectorySnapshot`.
|
||||||
|
|
||||||
|
Single-flight: concurrent callers coalesce onto a single fetch (see the
|
||||||
|
class docstring). On :class:`KeycloakError`, logs and returns the
|
||||||
|
last-good snapshot if one exists; otherwise re-raises.
|
||||||
|
"""
|
||||||
|
# Snapshot the generation we observed before contending for the lock.
|
||||||
|
observed_generation = self._generation
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# If the generation advanced while we were blocked, another caller
|
||||||
|
# just completed a fetch — coalesce onto their result.
|
||||||
|
if self._generation != observed_generation and self._snapshot is not None:
|
||||||
|
logger.debug(
|
||||||
|
"refresh coalesced onto concurrent fetch (generation %d)",
|
||||||
|
self._generation,
|
||||||
|
)
|
||||||
|
return self._snapshot
|
||||||
|
|
||||||
|
try:
|
||||||
|
snapshot = self._fetch_snapshot()
|
||||||
|
except KeycloakError:
|
||||||
|
if self._snapshot is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Keycloak refresh failed; serving last-good snapshot (%d users, %d groups)",
|
||||||
|
len(self._snapshot.users),
|
||||||
|
len(self._snapshot.groups),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return self._snapshot
|
||||||
|
logger.error(
|
||||||
|
"Keycloak refresh failed and no prior snapshot exists; re-raising",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._snapshot = snapshot
|
||||||
|
self._generation += 1
|
||||||
|
logger.info(
|
||||||
|
"Refreshed Keycloak snapshot: %d users, %d groups, %d groups with members",
|
||||||
|
len(snapshot.users),
|
||||||
|
len(snapshot.groups),
|
||||||
|
len(snapshot.members_by_group),
|
||||||
|
)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def current(self) -> DirectorySnapshot | None:
|
||||||
|
"""Return the last successfully fetched snapshot, or ``None``.
|
||||||
|
|
||||||
|
Non-blocking and thread-safe.
|
||||||
|
"""
|
||||||
|
# Reading the attribute under the lock guarantees we observe a fully
|
||||||
|
# published snapshot (and is non-blocking in practice — the only other
|
||||||
|
# holder is refresh(), and current() never waits on the KC fetch since
|
||||||
|
# it does not itself fetch). Snapshots are immutable, so the returned
|
||||||
|
# reference is safe to use without further locking.
|
||||||
|
with self._lock:
|
||||||
|
return self._snapshot
|
||||||
|
|
||||||
|
def _fetch_snapshot(self) -> DirectorySnapshot:
|
||||||
|
"""Perform the blocking KC enumeration and assemble a snapshot.
|
||||||
|
|
||||||
|
Must be called with ``self._lock`` held.
|
||||||
|
"""
|
||||||
|
users = self._client.get_all_users()
|
||||||
|
groups = self._client.get_all_groups()
|
||||||
|
|
||||||
|
members_by_group: dict[str, list[dict]] = {}
|
||||||
|
for group in groups:
|
||||||
|
group_id = group["id"]
|
||||||
|
members_by_group[group_id] = self._client.get_group_members(group_id)
|
||||||
|
|
||||||
|
return DirectorySnapshot(
|
||||||
|
users=users,
|
||||||
|
groups=groups,
|
||||||
|
members_by_group=members_by_group,
|
||||||
|
)
|
||||||
183
ldap-gateway/ldap_gateway/config.py
Normal file
183
ldap-gateway/ldap_gateway/config.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""Configuration loading for the LDAP gateway.
|
||||||
|
|
||||||
|
Parses the ``LDAP_*`` / ``KEYCLOAK_*`` environment variables documented in
|
||||||
|
``SPEC.md`` §11 into a frozen :class:`Config` dataclass. See ``CONTRACT.md`` for the
|
||||||
|
binding interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
# Keycloak
|
||||||
|
keycloak_url: str
|
||||||
|
keycloak_realm: str
|
||||||
|
keycloak_client_id: str
|
||||||
|
keycloak_client_secret: str
|
||||||
|
keycloak_verify_ssl: bool
|
||||||
|
keycloak_timeout: int
|
||||||
|
# LDAP server
|
||||||
|
base_dn: str # e.g. "dc=mgmt,dc=example,dc=com"
|
||||||
|
bind_host: str
|
||||||
|
port: int # plain / StartTLS, default 389
|
||||||
|
ldaps_port: int # LDAPS, default 636
|
||||||
|
tls_cert: str | None # path
|
||||||
|
tls_key: str | None # path
|
||||||
|
allow_anon: bool
|
||||||
|
service_accounts: dict[str, str] # bind DN (lowercased) -> password
|
||||||
|
# Identity (see identity.py)
|
||||||
|
identity_source: str # "username" | "cert_cn"
|
||||||
|
cert_cn_attribute: str # KC user attribute name for cert CN, default "certCN"
|
||||||
|
# POSIX
|
||||||
|
uid_min: int
|
||||||
|
uid_max: int
|
||||||
|
gid_min: int
|
||||||
|
gid_max: int
|
||||||
|
default_shell: str # default "/bin/bash"
|
||||||
|
home_base: str # default "/home"
|
||||||
|
# Caching / filtering
|
||||||
|
cache_ttl: int # seconds, default 60
|
||||||
|
refresh_interval: int # seconds, default = cache_ttl
|
||||||
|
exclude_disabled: bool # default True
|
||||||
|
exclude_service_accounts: bool # default True
|
||||||
|
group_include: list[str] # name globs, empty = all
|
||||||
|
group_exclude: list[str] # name globs
|
||||||
|
|
||||||
|
|
||||||
|
# --- parsing helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||||
|
_FALSE_VALUES = {"0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get(env: Mapping[str, str], key: str, default: str | None = None) -> str | None:
|
||||||
|
"""Return the env value for ``key``, treating empty/whitespace-only as unset."""
|
||||||
|
value = env.get(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
value = value.strip()
|
||||||
|
if value == "":
|
||||||
|
return default
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bool(env: Mapping[str, str], key: str, default: bool) -> bool:
|
||||||
|
raw = _get(env, key)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
lowered = raw.lower()
|
||||||
|
if lowered in _TRUE_VALUES:
|
||||||
|
return True
|
||||||
|
if lowered in _FALSE_VALUES:
|
||||||
|
return False
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid boolean value for {key}: {raw!r} "
|
||||||
|
f"(expected one of {sorted(_TRUE_VALUES | _FALSE_VALUES)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int(env: Mapping[str, str], key: str, default: int) -> int:
|
||||||
|
raw = _get(env, key)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid integer value for {key}: {raw!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _get_list(env: Mapping[str, str], key: str) -> list[str]:
|
||||||
|
"""Parse a comma-separated list; empty/unset -> empty list."""
|
||||||
|
raw = _get(env, key)
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_service_accounts(env: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
"""Parse ``LDAP_SERVICE_ACCOUNTS`` JSON ({"dn": "password", ...}); keys lowercased."""
|
||||||
|
raw = _get(env, "LDAP_SERVICE_ACCOUNTS")
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"Invalid JSON in LDAP_SERVICE_ACCOUNTS: {exc}") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("LDAP_SERVICE_ACCOUNTS must be a JSON object mapping bind DN -> password")
|
||||||
|
accounts: dict[str, str] = {}
|
||||||
|
for dn, password in parsed.items():
|
||||||
|
if not isinstance(dn, str) or not isinstance(password, str):
|
||||||
|
raise ValueError("LDAP_SERVICE_ACCOUNTS keys and values must all be strings")
|
||||||
|
accounts[dn.lower()] = password
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(env: Mapping[str, str] | None = None) -> Config:
|
||||||
|
"""Build a :class:`Config` from environment variables.
|
||||||
|
|
||||||
|
``env`` defaults to ``os.environ``. Raises :class:`ValueError` with a clear
|
||||||
|
message when a required variable (``KEYCLOAK_URL``, ``LDAP_BASE_DN``) is missing.
|
||||||
|
"""
|
||||||
|
if env is None:
|
||||||
|
env = os.environ
|
||||||
|
|
||||||
|
# Required vars.
|
||||||
|
keycloak_url = _get(env, "KEYCLOAK_URL")
|
||||||
|
if keycloak_url is None:
|
||||||
|
raise ValueError("KEYCLOAK_URL is required but not set")
|
||||||
|
base_dn = _get(env, "LDAP_BASE_DN")
|
||||||
|
if base_dn is None:
|
||||||
|
raise ValueError("LDAP_BASE_DN is required but not set")
|
||||||
|
|
||||||
|
cache_ttl = _get_int(env, "LDAP_CACHE_TTL", 60)
|
||||||
|
# refresh_interval defaults to cache_ttl when unset.
|
||||||
|
refresh_raw = _get(env, "LDAP_REFRESH_INTERVAL")
|
||||||
|
refresh_interval = (
|
||||||
|
cache_ttl if refresh_raw is None else _get_int(env, "LDAP_REFRESH_INTERVAL", cache_ttl)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
# Keycloak
|
||||||
|
keycloak_url=keycloak_url,
|
||||||
|
keycloak_realm=_get(env, "KEYCLOAK_REALM", "mgmt") or "mgmt",
|
||||||
|
keycloak_client_id=_get(env, "KEYCLOAK_CLIENT_ID", "") or "",
|
||||||
|
keycloak_client_secret=_get(env, "KEYCLOAK_CLIENT_SECRET", "") or "",
|
||||||
|
keycloak_verify_ssl=_get_bool(env, "KEYCLOAK_VERIFY_SSL", True),
|
||||||
|
keycloak_timeout=_get_int(env, "KEYCLOAK_TIMEOUT", 30),
|
||||||
|
# LDAP server
|
||||||
|
base_dn=base_dn,
|
||||||
|
bind_host=_get(env, "LDAP_BIND_HOST", "0.0.0.0") or "0.0.0.0",
|
||||||
|
port=_get_int(env, "LDAP_PORT", 389),
|
||||||
|
ldaps_port=_get_int(env, "LDAPS_PORT", 636),
|
||||||
|
tls_cert=_get(env, "LDAP_TLS_CERT"),
|
||||||
|
tls_key=_get(env, "LDAP_TLS_KEY"),
|
||||||
|
allow_anon=_get_bool(env, "LDAP_ALLOW_ANON", False),
|
||||||
|
service_accounts=_parse_service_accounts(env),
|
||||||
|
# Identity
|
||||||
|
identity_source=_get(env, "LDAP_IDENTITY_SOURCE", "username") or "username",
|
||||||
|
cert_cn_attribute=_get(env, "LDAP_CERT_CN_ATTRIBUTE", "certCN") or "certCN",
|
||||||
|
# POSIX
|
||||||
|
uid_min=_get_int(env, "LDAP_UID_MIN", 100000),
|
||||||
|
uid_max=_get_int(env, "LDAP_UID_MAX", 599999),
|
||||||
|
gid_min=_get_int(env, "LDAP_GID_MIN", 100000),
|
||||||
|
gid_max=_get_int(env, "LDAP_GID_MAX", 599999),
|
||||||
|
default_shell=_get(env, "LDAP_DEFAULT_SHELL", "/bin/bash") or "/bin/bash",
|
||||||
|
home_base=_get(env, "LDAP_HOME_BASE", "/home") or "/home",
|
||||||
|
# Caching / filtering
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
refresh_interval=refresh_interval,
|
||||||
|
exclude_disabled=_get_bool(env, "LDAP_EXCLUDE_DISABLED", True),
|
||||||
|
exclude_service_accounts=_get_bool(env, "LDAP_EXCLUDE_SERVICE_ACCOUNTS", True),
|
||||||
|
group_include=_get_list(env, "LDAP_GROUP_INCLUDE"),
|
||||||
|
group_exclude=_get_list(env, "LDAP_GROUP_EXCLUDE"),
|
||||||
|
)
|
||||||
270
ldap-gateway/ldap_gateway/directory.py
Normal file
270
ldap-gateway/ldap_gateway/directory.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""Build and hold the in-memory LDAP directory tree from cached Keycloak data.
|
||||||
|
|
||||||
|
On each cache refresh the whole :class:`~ldaptor.inmemory.ReadOnlyInMemoryLDAPEntry`
|
||||||
|
tree is rebuilt from a :class:`~ldap_gateway.cache.DirectorySnapshot` and swapped in
|
||||||
|
atomically (see ``SPEC.md`` §2/§4 and ``CONTRACT.md`` "directory.py"). ldaptor then
|
||||||
|
handles search scoping/filters against the tree for free, and the tree is naturally
|
||||||
|
read-only.
|
||||||
|
|
||||||
|
The DIT (``SPEC.md`` §4)::
|
||||||
|
|
||||||
|
<base_dn> (dcObject, organization)
|
||||||
|
├── ou=people,<base_dn> (organizationalUnit)
|
||||||
|
│ └── <rdn>=<id>,ou=people,… (inetOrgPerson + posixAccount + shadowAccount)
|
||||||
|
├── ou=groups,<base_dn> (organizationalUnit)
|
||||||
|
│ └── cn=<name>,ou=groups,… (posixGroup [+ groupOfNames when non-empty])
|
||||||
|
└── ou=services,<base_dn> (service bind accounts; no credentials stored)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from ldaptor.inmemory import ReadOnlyInMemoryLDAPEntry
|
||||||
|
from ldaptor.protocols.ldap.distinguishedname import DistinguishedName
|
||||||
|
|
||||||
|
from . import mapping
|
||||||
|
from .cache import DirectorySnapshot
|
||||||
|
from .config import Config
|
||||||
|
from .identity import IdentityResolver
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# CONTRACT-NOTE: the installed ldaptor (>=21) exposes the in-memory tree class as
|
||||||
|
# ``ldaptor.inmemory.ReadOnlyInMemoryLDAPEntry``. Its root is constructed directly as
|
||||||
|
# ``ReadOnlyInMemoryLDAPEntry(DistinguishedName(<dn>), attributes)`` and children are
|
||||||
|
# added with ``parent.addChild(rdn_str, attributes)`` — the child DN is derived from
|
||||||
|
# the parent DN + the RDN string, so we pass an *escaped* "attr=value" RDN and rely on
|
||||||
|
# ldaptor's DN parsing. This matches CONTRACT.md "directory.py" (``addChild``).
|
||||||
|
|
||||||
|
|
||||||
|
def _root_dc_attributes(base_dn: str) -> dict[str, list[str]]:
|
||||||
|
"""Derive the root entry attributes (dcObject + organization) from ``base_dn``.
|
||||||
|
|
||||||
|
The first RDN of the base DN supplies the ``dc`` value (e.g. ``dc=mgmt`` ->
|
||||||
|
``dc: mgmt``); ``o`` mirrors that value to satisfy the ``organization``
|
||||||
|
objectClass requirement for an ``o`` attribute.
|
||||||
|
"""
|
||||||
|
attrs: dict[str, list[str]] = {
|
||||||
|
"objectClass": ["top", "dcObject", "organization"],
|
||||||
|
}
|
||||||
|
dn = DistinguishedName(base_dn)
|
||||||
|
rdns = dn.split()
|
||||||
|
dc_value: str | None = None
|
||||||
|
if rdns:
|
||||||
|
# The first RDN's first attributeTypeAndValue (e.g. dc=mgmt).
|
||||||
|
for atv in rdns[0].split():
|
||||||
|
if atv.attributeType.lower() == "dc":
|
||||||
|
dc_value = atv.value
|
||||||
|
break
|
||||||
|
if dc_value:
|
||||||
|
attrs["dc"] = [dc_value]
|
||||||
|
attrs["o"] = [dc_value]
|
||||||
|
else:
|
||||||
|
# CONTRACT-NOTE: SPEC §4 assumes a dc-style base DN. If the base DN does not
|
||||||
|
# begin with a dc=, fall back to the literal base DN as the organization name
|
||||||
|
# so the root entry still satisfies the organization objectClass (needs ``o``).
|
||||||
|
logger.warning(
|
||||||
|
"base_dn %r does not start with a dc= RDN; using it as the o= value",
|
||||||
|
base_dn,
|
||||||
|
)
|
||||||
|
attrs["o"] = [base_dn]
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
def _build_root(config: Config) -> ReadOnlyInMemoryLDAPEntry:
|
||||||
|
"""Create a root entry plus the three (empty) organizational units."""
|
||||||
|
root = ReadOnlyInMemoryLDAPEntry(
|
||||||
|
DistinguishedName(config.base_dn),
|
||||||
|
_root_dc_attributes(config.base_dn),
|
||||||
|
)
|
||||||
|
root.addChild("ou=people", {"objectClass": ["top", "organizationalUnit"], "ou": ["people"]})
|
||||||
|
root.addChild("ou=groups", {"objectClass": ["top", "organizationalUnit"], "ou": ["groups"]})
|
||||||
|
root.addChild("ou=services", {"objectClass": ["top", "organizationalUnit"], "ou": ["services"]})
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _child(root: ReadOnlyInMemoryLDAPEntry, ou_rdn: str) -> ReadOnlyInMemoryLDAPEntry:
|
||||||
|
"""Return an OU child of ``root`` by its RDN (e.g. ``ou=people``)."""
|
||||||
|
return root._children[ou_rdn] # noqa: SLF001 -- ldaptor exposes children only here
|
||||||
|
|
||||||
|
|
||||||
|
def _service_cn(service_dn: str) -> str | None:
|
||||||
|
"""Extract the ``cn`` value from a service-account bind DN, if present.
|
||||||
|
|
||||||
|
e.g. ``cn=svc-readonly,ou=services,dc=…`` -> ``svc-readonly``. Returns the value
|
||||||
|
of the first ``cn`` attribute in the first RDN, else ``None``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dn = DistinguishedName(service_dn)
|
||||||
|
except Exception: # noqa: BLE001 -- malformed configured DN; log and skip below
|
||||||
|
logger.warning("Service-account DN %r is not a valid DN; skipping", service_dn)
|
||||||
|
return None
|
||||||
|
rdns = dn.split()
|
||||||
|
if not rdns:
|
||||||
|
return None
|
||||||
|
for atv in rdns[0].split():
|
||||||
|
if atv.attributeType.lower() == "cn":
|
||||||
|
return atv.value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_tree(
|
||||||
|
snapshot: DirectorySnapshot,
|
||||||
|
config: Config,
|
||||||
|
resolver: IdentityResolver,
|
||||||
|
) -> ReadOnlyInMemoryLDAPEntry:
|
||||||
|
"""Build a fresh in-memory directory tree from a Keycloak snapshot.
|
||||||
|
|
||||||
|
Users and groups that pass the inclusion filters (``mapping.should_include_*``)
|
||||||
|
and resolve to an RDN value are added; members that did not make it into the
|
||||||
|
user set are dropped from their groups' membership. Service-account entries are
|
||||||
|
added from ``config.service_accounts`` keys **without** their passwords.
|
||||||
|
"""
|
||||||
|
root = _build_root(config)
|
||||||
|
people = _child(root, "ou=people")
|
||||||
|
groups = _child(root, "ou=groups")
|
||||||
|
services = _child(root, "ou=services")
|
||||||
|
|
||||||
|
members_by_group = snapshot.members_by_group
|
||||||
|
|
||||||
|
# group_id -> group dict, restricted to includable groups (used for memberOf).
|
||||||
|
included_groups = [g for g in snapshot.groups if mapping.should_include_group(g, config)]
|
||||||
|
group_dn_by_id: dict[str, str] = {
|
||||||
|
g["id"]: mapping.group_dn(g.get("name") or "", config) for g in included_groups
|
||||||
|
}
|
||||||
|
# user_id -> list of included group ids it is a direct member of.
|
||||||
|
group_ids_by_user: dict[str, list[str]] = {}
|
||||||
|
for group in included_groups:
|
||||||
|
gid = group["id"]
|
||||||
|
for member in members_by_group.get(gid, []):
|
||||||
|
uid = member.get("id")
|
||||||
|
if uid is None:
|
||||||
|
continue
|
||||||
|
group_ids_by_user.setdefault(uid, []).append(gid)
|
||||||
|
|
||||||
|
# user_id -> (rdn_value, user_dn) for users that made it into the directory.
|
||||||
|
user_index: dict[str, tuple[str, str]] = {}
|
||||||
|
|
||||||
|
for kc_user in snapshot.users:
|
||||||
|
if not mapping.should_include_user(kc_user, config):
|
||||||
|
continue
|
||||||
|
rdn_value = resolver.resolve(kc_user)
|
||||||
|
if rdn_value is None:
|
||||||
|
# resolver already logs the reason at debug; note the skip explicitly.
|
||||||
|
logger.info(
|
||||||
|
"Skipping user %s: identity resolver returned no RDN value",
|
||||||
|
kc_user.get("id", "<unknown>"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
user_id = kc_user.get("id")
|
||||||
|
member_group_dns = [
|
||||||
|
group_dn_by_id[gid]
|
||||||
|
for gid in group_ids_by_user.get(user_id, [])
|
||||||
|
if gid in group_dn_by_id
|
||||||
|
]
|
||||||
|
attrs = mapping.user_entry_attributes(
|
||||||
|
kc_user, rdn_value, member_group_dns, config, resolver
|
||||||
|
)
|
||||||
|
rdn = f"{resolver.rdn_attr}={mapping.escape_dn_value(rdn_value)}"
|
||||||
|
try:
|
||||||
|
entry = people.addChild(rdn, attrs)
|
||||||
|
except Exception: # noqa: BLE001 -- e.g. duplicate RDN; log and skip the dupe
|
||||||
|
logger.warning(
|
||||||
|
"Could not add user entry %s under ou=people; skipping",
|
||||||
|
rdn,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if user_id is not None:
|
||||||
|
user_index[user_id] = (rdn_value, entry.dn.getText())
|
||||||
|
|
||||||
|
# --- Groups ------------------------------------------------------------
|
||||||
|
for group in included_groups:
|
||||||
|
gid = group["id"]
|
||||||
|
name = group.get("name") or ""
|
||||||
|
member_rdn_values: list[str] = []
|
||||||
|
member_dns: list[str] = []
|
||||||
|
for member in members_by_group.get(gid, []):
|
||||||
|
uid = member.get("id")
|
||||||
|
indexed = user_index.get(uid) if uid is not None else None
|
||||||
|
if indexed is None:
|
||||||
|
# Member not in the directory (excluded/skipped) — drop it.
|
||||||
|
continue
|
||||||
|
rdn_value, dn = indexed
|
||||||
|
member_rdn_values.append(rdn_value)
|
||||||
|
member_dns.append(dn)
|
||||||
|
|
||||||
|
attrs = mapping.group_entry_attributes(group, member_rdn_values, member_dns, config)
|
||||||
|
rdn = f"cn={mapping.escape_dn_value(name)}"
|
||||||
|
try:
|
||||||
|
groups.addChild(rdn, attrs)
|
||||||
|
except Exception: # noqa: BLE001 -- duplicate/invalid group name; log + skip
|
||||||
|
logger.warning(
|
||||||
|
"Could not add group entry %s under ou=groups; skipping",
|
||||||
|
rdn,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Service accounts --------------------------------------------------
|
||||||
|
# Entries exist so binds resolve a DN, but the password is NEVER stored here
|
||||||
|
# (SPEC §8): bind.validate_service_bind checks config.service_accounts directly.
|
||||||
|
for service_dn in config.service_accounts:
|
||||||
|
cn = _service_cn(service_dn)
|
||||||
|
if not cn:
|
||||||
|
logger.warning(
|
||||||
|
"Service-account DN %r has no cn= RDN; not adding an ou=services entry",
|
||||||
|
service_dn,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
attrs = {
|
||||||
|
"objectClass": ["top", "applicationProcess"],
|
||||||
|
"cn": [cn],
|
||||||
|
}
|
||||||
|
rdn = f"cn={mapping.escape_dn_value(cn)}"
|
||||||
|
try:
|
||||||
|
services.addChild(rdn, attrs)
|
||||||
|
except Exception: # noqa: BLE001 -- duplicate cn across service DNs; log + skip
|
||||||
|
logger.warning(
|
||||||
|
"Could not add service entry %s under ou=services; skipping",
|
||||||
|
rdn,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Built directory tree: %d user entries, %d group entries, %d service entries",
|
||||||
|
len(user_index),
|
||||||
|
len(included_groups),
|
||||||
|
len(config.service_accounts),
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryHolder:
|
||||||
|
"""Holds the current directory tree and supports atomic refresh swaps.
|
||||||
|
|
||||||
|
The tree starts as an empty-but-valid root (base DN + the three empty OUs) so
|
||||||
|
searches issued before the first cache refresh succeed with zero results. Each
|
||||||
|
:meth:`swap` rebuilds the tree from a snapshot and replaces it under a lock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config, resolver: IdentityResolver) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._resolver = resolver
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._tree: ReadOnlyInMemoryLDAPEntry = _build_root(config)
|
||||||
|
|
||||||
|
def swap(self, snapshot: DirectorySnapshot) -> None:
|
||||||
|
"""Rebuild the tree from ``snapshot`` and atomically replace the current one."""
|
||||||
|
new_tree = build_tree(snapshot, self._config, self._resolver)
|
||||||
|
with self._lock:
|
||||||
|
self._tree = new_tree
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tree(self) -> ReadOnlyInMemoryLDAPEntry:
|
||||||
|
"""The current root entry (an ``IConnectedLDAPEntry``)."""
|
||||||
|
with self._lock:
|
||||||
|
return self._tree
|
||||||
166
ldap-gateway/ldap_gateway/handlers.py
Normal file
166
ldap-gateway/ldap_gateway/handlers.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""Twisted/ldaptor protocol handlers for the LDAP gateway.
|
||||||
|
|
||||||
|
Implements a read-only ``LDAPServer`` subclass that:
|
||||||
|
|
||||||
|
* permits **search** against the in-memory directory tree held by a
|
||||||
|
:class:`~ldap_gateway.directory.DirectoryHolder`,
|
||||||
|
* permits **bind** only for anonymous (when ``config.allow_anon``) and configured
|
||||||
|
service accounts (validated by :func:`ldap_gateway.bind.validate_service_bind`), and
|
||||||
|
* rejects all **writes** (add/modify/delete/modify-DN) with ``unwillingToPerform``.
|
||||||
|
|
||||||
|
See ``SPEC.md`` §1, §8 and ``CONTRACT.md`` "handlers.py".
|
||||||
|
|
||||||
|
ldaptor API targeted (ldaptor 21.2.0):
|
||||||
|
* ``LDAPServer`` resolves the directory root for *every* request via
|
||||||
|
``ldaptor.interfaces.IConnectedLDAPEntry(self.factory)`` (see ``ldapserver.py``
|
||||||
|
``handle_LDAPSearchRequest`` / ``handle_LDAPBindRequest``). We register an adapter
|
||||||
|
from :class:`LDAPServerFactory` to ``IConnectedLDAPEntry`` that returns
|
||||||
|
``factory.holder.tree`` **live**, so each request sees the most recently swapped-in
|
||||||
|
tree (cache refreshes are picked up without rebinding). See CONTRACT-NOTE below.
|
||||||
|
* ``LDAPBindRequest`` carries ``.version`` (must be 3), ``.dn`` (bytes) and ``.auth``
|
||||||
|
(bytes password). A successful bind replies with
|
||||||
|
``pureldap.LDAPBindResponse(resultCode=0, matchedDN=...)``.
|
||||||
|
* Errors are raised as ``ldaperrors.LDAPException`` subclasses; ``BaseLDAPServer``
|
||||||
|
catches them and emits the matching response PDU (``fail_LDAPBindRequest`` etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ldaptor import interfaces
|
||||||
|
from ldaptor.protocols import pureldap
|
||||||
|
from ldaptor.protocols.ldap import ldaperrors
|
||||||
|
from ldaptor.protocols.ldap.ldapserver import LDAPServer
|
||||||
|
from twisted.internet.protocol import ServerFactory
|
||||||
|
from twisted.python import components
|
||||||
|
|
||||||
|
from . import bind
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# CONTRACT-NOTE: directory.py is landing concurrently. We only need its type for
|
||||||
|
# annotations, so import it under TYPE_CHECKING to avoid a hard import-time
|
||||||
|
# dependency (handlers.py and its tests load even before directory.py exists). At
|
||||||
|
# runtime ``LDAPServerFactory`` only relies on the holder exposing a ``.tree``
|
||||||
|
# property, per the DirectoryHolder contract.
|
||||||
|
from .directory import DirectoryHolder
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayLDAPServer(LDAPServer):
|
||||||
|
"""Read-only LDAP server: search + service/anon bind only; writes refused.
|
||||||
|
|
||||||
|
``self.factory`` is an :class:`LDAPServerFactory`, exposing ``.holder`` and
|
||||||
|
``.config``. Search handling is inherited unchanged from :class:`LDAPServer` and
|
||||||
|
resolves the root via the registered ``IConnectedLDAPEntry`` adapter (live read of
|
||||||
|
``holder.tree``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ bind --
|
||||||
|
|
||||||
|
def handle_LDAPBindRequest(self, request, controls, reply): # type: ignore[no-untyped-def]
|
||||||
|
"""Authenticate a bind request.
|
||||||
|
|
||||||
|
Policy (``SPEC.md`` §8):
|
||||||
|
* anonymous (empty DN and empty password) -> allowed only if
|
||||||
|
``config.allow_anon``, else ``invalidCredentials``;
|
||||||
|
* a DN present in ``config.service_accounts`` with a correct password ->
|
||||||
|
success;
|
||||||
|
* everything else -> ``invalidCredentials``.
|
||||||
|
|
||||||
|
We deliberately do **not** delegate to ``LDAPServer.handle_LDAPBindRequest``
|
||||||
|
(which would attempt an entry ``.bind()`` against the tree); the gateway never
|
||||||
|
authenticates directory users.
|
||||||
|
"""
|
||||||
|
if request.version != 3:
|
||||||
|
raise ldaperrors.LDAPProtocolError("Version %u not supported" % request.version)
|
||||||
|
self.checkControls(controls)
|
||||||
|
|
||||||
|
config: Config = self.factory.config
|
||||||
|
dn_bytes: bytes = request.dn or b""
|
||||||
|
auth_bytes: bytes = request.auth or b""
|
||||||
|
|
||||||
|
# Anonymous bind: empty DN and empty password.
|
||||||
|
if dn_bytes == b"" and auth_bytes == b"":
|
||||||
|
if config.allow_anon:
|
||||||
|
self.boundUser = None
|
||||||
|
logger.info("Anonymous bind accepted")
|
||||||
|
return pureldap.LDAPBindResponse(resultCode=ldaperrors.Success.resultCode)
|
||||||
|
logger.info("Anonymous bind rejected (allow_anon disabled)")
|
||||||
|
raise ldaperrors.LDAPInvalidCredentials()
|
||||||
|
|
||||||
|
# A DN with an empty password (unauthenticated bind) is treated as a failed
|
||||||
|
# credentials attempt — never a successful anonymous bind.
|
||||||
|
dn_text = dn_bytes.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if bind.validate_service_bind(dn_text, auth_bytes, config):
|
||||||
|
self.boundUser = None
|
||||||
|
return pureldap.LDAPBindResponse(
|
||||||
|
resultCode=ldaperrors.Success.resultCode,
|
||||||
|
matchedDN=dn_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Any other DN/user bind (including unknown DNs and directory users) is denied.
|
||||||
|
logger.info("Bind rejected for DN %r", dn_text)
|
||||||
|
raise ldaperrors.LDAPInvalidCredentials()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- writes --
|
||||||
|
#
|
||||||
|
# Read-only gateway: all mutating operations are refused with
|
||||||
|
# ``unwillingToPerform`` (53) and nothing is ever mutated. We override the
|
||||||
|
# handlers entirely so the base-class lookup/mutate logic never runs.
|
||||||
|
|
||||||
|
def handle_LDAPAddRequest(self, request, controls, reply): # type: ignore[no-untyped-def]
|
||||||
|
self.checkControls(controls)
|
||||||
|
logger.info("Add request refused (read-only gateway)")
|
||||||
|
raise ldaperrors.LDAPUnwillingToPerform("Directory is read-only")
|
||||||
|
|
||||||
|
def handle_LDAPModifyRequest(self, request, controls, reply): # type: ignore[no-untyped-def]
|
||||||
|
self.checkControls(controls)
|
||||||
|
logger.info("Modify request refused (read-only gateway)")
|
||||||
|
raise ldaperrors.LDAPUnwillingToPerform("Directory is read-only")
|
||||||
|
|
||||||
|
def handle_LDAPDelRequest(self, request, controls, reply): # type: ignore[no-untyped-def]
|
||||||
|
self.checkControls(controls)
|
||||||
|
logger.info("Delete request refused (read-only gateway)")
|
||||||
|
raise ldaperrors.LDAPUnwillingToPerform("Directory is read-only")
|
||||||
|
|
||||||
|
def handle_LDAPModifyDNRequest(self, request, controls, reply): # type: ignore[no-untyped-def]
|
||||||
|
self.checkControls(controls)
|
||||||
|
logger.info("ModifyDN request refused (read-only gateway)")
|
||||||
|
raise ldaperrors.LDAPUnwillingToPerform("Directory is read-only")
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPServerFactory(ServerFactory):
|
||||||
|
"""Factory that wires each :class:`GatewayLDAPServer` to the directory + config."""
|
||||||
|
|
||||||
|
protocol = GatewayLDAPServer
|
||||||
|
|
||||||
|
def __init__(self, holder: "DirectoryHolder", config: Config) -> None:
|
||||||
|
self.holder = holder
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def buildProtocol(self, addr): # type: ignore[no-untyped-def]
|
||||||
|
proto = self.protocol()
|
||||||
|
proto.factory = self
|
||||||
|
return proto
|
||||||
|
|
||||||
|
|
||||||
|
# CONTRACT-NOTE: The contract suggested either setting ``self.root = holder.tree`` at
|
||||||
|
# buildProtocol time OR a live root-accessor, and to PREFER live read. ldaptor 21.2.0's
|
||||||
|
# ``LDAPServer`` does not read ``self.factory.root`` directly; it resolves the root on
|
||||||
|
# *every* request via ``ldaptor.interfaces.IConnectedLDAPEntry(self.factory)`` (an
|
||||||
|
# interface adaptation of the factory). We therefore register an adapter from
|
||||||
|
# ``LDAPServerFactory`` to ``IConnectedLDAPEntry`` that returns ``factory.holder.tree``.
|
||||||
|
# Because the adaptation runs per request, every search/bind sees the latest tree the
|
||||||
|
# DirectoryHolder has swapped in — i.e. a true live read, picking up cache refreshes
|
||||||
|
# without reconnecting. (The stock demo adapter returns ``factory.root``, a snapshot;
|
||||||
|
# ours reads ``holder.tree`` instead, which is the live, atomically-swapped root.)
|
||||||
|
def _factory_to_root(factory: LDAPServerFactory): # type: ignore[no-untyped-def]
|
||||||
|
return factory.holder.tree
|
||||||
|
|
||||||
|
|
||||||
|
components.registerAdapter(_factory_to_root, LDAPServerFactory, interfaces.IConnectedLDAPEntry)
|
||||||
103
ldap-gateway/ldap_gateway/identity.py
Normal file
103
ldap-gateway/ldap_gateway/identity.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Pluggable user identity resolution (the user entry's RDN attribute + value).
|
||||||
|
|
||||||
|
See ``SPEC.md`` §5.3 and the ``identity.py`` section of ``CONTRACT.md``. The identity
|
||||||
|
key (RDN attribute + the value pulled from a Keycloak user) is not hardcoded; a
|
||||||
|
:class:`IdentityResolver` is selected by config so it can be swapped without touching
|
||||||
|
mapping/directory code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdentityResolver(Protocol):
|
||||||
|
"""Resolves a Keycloak user dict to the LDAP RDN attribute and value."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rdn_attr(self) -> str:
|
||||||
|
"""LDAP attribute used as the user RDN, e.g. ``"uid"``."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve(self, kc_user: dict) -> str | None:
|
||||||
|
"""RDN value for this user, or ``None`` to SKIP the user from the directory."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class UsernameIdentityResolver:
|
||||||
|
"""Default resolver: RDN ``uid=<KC username>``."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rdn_attr(self) -> str:
|
||||||
|
return "uid"
|
||||||
|
|
||||||
|
def resolve(self, kc_user: dict) -> str | None:
|
||||||
|
username = kc_user.get("username")
|
||||||
|
if not username:
|
||||||
|
logger.debug("Skipping user %s: no username present", kc_user.get("id", "<unknown>"))
|
||||||
|
return None
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
class CertCNIdentityResolver:
|
||||||
|
"""STUB resolver keyed on a Keycloak cert-CN custom attribute (RDN ``cn=<value>``).
|
||||||
|
|
||||||
|
STUB — details TBD (see SPEC §5.3). This reads the configured KC user attribute
|
||||||
|
if present and returns its first value; if the attribute is absent (or empty) it
|
||||||
|
returns ``None`` so the user is skipped from the directory (logged). It NEVER
|
||||||
|
crashes the server.
|
||||||
|
|
||||||
|
CONTRACT-NOTE: SPEC.md §5.3 describes this stub as raising a clear
|
||||||
|
"not yet configured" error until the cert-CN details land. CONTRACT.md's
|
||||||
|
identity.py section is binding and explicitly overrides that: it requires
|
||||||
|
"Do NOT crash the server" and "if the attribute is absent return None (skip + log)".
|
||||||
|
Implemented to the CONTRACT (never raises from resolve()).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cert_cn_attribute: str) -> None:
|
||||||
|
self._cert_cn_attribute = cert_cn_attribute
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rdn_attr(self) -> str:
|
||||||
|
return "cn"
|
||||||
|
|
||||||
|
def resolve(self, kc_user: dict) -> str | None:
|
||||||
|
attributes = kc_user.get("attributes") or {}
|
||||||
|
values = attributes.get(self._cert_cn_attribute)
|
||||||
|
if not values:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping user %s: cert CN attribute %r absent or empty",
|
||||||
|
kc_user.get("id", "<unknown>"),
|
||||||
|
self._cert_cn_attribute,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# KC attribute values are lists[str]; take the first non-empty value.
|
||||||
|
first = values[0]
|
||||||
|
if not first:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping user %s: cert CN attribute %r first value empty",
|
||||||
|
kc_user.get("id", "<unknown>"),
|
||||||
|
self._cert_cn_attribute,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return first
|
||||||
|
|
||||||
|
|
||||||
|
def get_identity_resolver(config: Config) -> IdentityResolver:
|
||||||
|
"""Build the configured identity resolver.
|
||||||
|
|
||||||
|
``"username"`` -> :class:`UsernameIdentityResolver`;
|
||||||
|
``"cert_cn"`` -> :class:`CertCNIdentityResolver`; anything else -> ``ValueError``.
|
||||||
|
"""
|
||||||
|
source = config.identity_source
|
||||||
|
if source == "username":
|
||||||
|
return UsernameIdentityResolver()
|
||||||
|
if source == "cert_cn":
|
||||||
|
return CertCNIdentityResolver(config.cert_cn_attribute)
|
||||||
|
raise ValueError(f"Unknown identity_source {source!r} (expected 'username' or 'cert_cn')")
|
||||||
290
ldap-gateway/ldap_gateway/kc_client.py
Normal file
290
ldap-gateway/ldap_gateway/kc_client.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""Self-contained Keycloak Admin REST API client for the LDAP gateway.
|
||||||
|
|
||||||
|
Synchronous wrapper over the Keycloak Admin API. It depends only on the
|
||||||
|
standard library, ``requests``, and :mod:`ldap_gateway.config` — it does NOT
|
||||||
|
import anything from the main backend application. The cache layer drives this
|
||||||
|
client from a thread (via Twisted's ``deferToThread``), so every method here is
|
||||||
|
plain blocking I/O.
|
||||||
|
|
||||||
|
Responsibilities (see CONTRACT.md "kc_client.py" and SPEC.md §3):
|
||||||
|
|
||||||
|
* Obtain a service-account access token via the OAuth2 ``client_credentials``
|
||||||
|
grant against ``{url}/realms/{realm}/protocol/openid-connect/token`` and cache
|
||||||
|
it in memory, refreshing shortly before expiry.
|
||||||
|
* ``get_all_users`` — paginated walk of ``/admin/realms/{realm}/users`` returning
|
||||||
|
full user dicts (including ``attributes``).
|
||||||
|
* ``get_all_groups`` — flattened list of every group (top-level + all
|
||||||
|
descendants), each carrying ``id``, ``name`` and ``attributes``.
|
||||||
|
* ``get_group_members`` — paginated members of a single group.
|
||||||
|
|
||||||
|
On any transport error or non-2xx response a :class:`KeycloakError` is raised
|
||||||
|
with a generic message; details are logged server-side only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Page size for paginated Admin API walks (users / members).
|
||||||
|
_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
# Refresh the cached token this many seconds before it actually expires, so an
|
||||||
|
# in-flight request never races the expiry boundary.
|
||||||
|
_TOKEN_REFRESH_BUFFER = 30
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakError(Exception):
|
||||||
|
"""Raised when a Keycloak Admin API call fails (transport or non-2xx).
|
||||||
|
|
||||||
|
The message is intentionally generic; the underlying detail is logged
|
||||||
|
server-side rather than surfaced to callers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakClient:
|
||||||
|
"""Synchronous client for the Keycloak Admin REST API.
|
||||||
|
|
||||||
|
A single :class:`requests.Session` is reused across calls so the TLS
|
||||||
|
handshake / connection is amortised. The access token is cached in memory
|
||||||
|
and refreshed with a buffer before expiry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._base = config.keycloak_url.rstrip("/")
|
||||||
|
self._realm = config.keycloak_realm
|
||||||
|
self._verify = config.keycloak_verify_ssl
|
||||||
|
self._timeout = config.keycloak_timeout
|
||||||
|
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.verify = self._verify
|
||||||
|
|
||||||
|
# Cached token state.
|
||||||
|
self._token: str | None = None
|
||||||
|
self._token_expires_at: float = 0.0
|
||||||
|
|
||||||
|
# ── URLs ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _token_url(self) -> str:
|
||||||
|
return f"{self._base}/realms/{self._realm}/protocol/openid-connect/token"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _admin_url(self) -> str:
|
||||||
|
return f"{self._base}/admin/realms/{self._realm}"
|
||||||
|
|
||||||
|
# ── Token management ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_token(self) -> str:
|
||||||
|
"""Return a valid access token, fetching a new one if needed.
|
||||||
|
|
||||||
|
The cached token is reused until it is within ``_TOKEN_REFRESH_BUFFER``
|
||||||
|
seconds of expiry, at which point a fresh ``client_credentials`` grant
|
||||||
|
is performed.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._token and now < self._token_expires_at - _TOKEN_REFRESH_BUFFER:
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = self._session.post(
|
||||||
|
self._token_url,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self._config.keycloak_client_id,
|
||||||
|
"client_secret": self._config.keycloak_client_secret,
|
||||||
|
},
|
||||||
|
timeout=self._timeout,
|
||||||
|
verify=self._verify,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error("Keycloak token request failed (%s): %s", self._token_url, exc)
|
||||||
|
raise KeycloakError("Failed to obtain Keycloak token") from exc
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"Keycloak token request returned %s for %s: %s",
|
||||||
|
resp.status_code,
|
||||||
|
self._token_url,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
raise KeycloakError("Failed to obtain Keycloak token")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
token = data["access_token"]
|
||||||
|
except (ValueError, KeyError, TypeError) as exc:
|
||||||
|
logger.error("Malformed Keycloak token response: %s", exc)
|
||||||
|
raise KeycloakError("Malformed Keycloak token response") from exc
|
||||||
|
|
||||||
|
# ``expires_in`` is seconds; fall back to a conservative default if KC
|
||||||
|
# omits it. Keyed off monotonic time so wall-clock changes don't matter.
|
||||||
|
expires_in = data.get("expires_in", 300)
|
||||||
|
self._token = token
|
||||||
|
self._token_expires_at = now + float(expires_in)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self._get_token()}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Low-level request helper ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
"""Perform an authenticated GET against the Admin API and return JSON.
|
||||||
|
|
||||||
|
Raises :class:`KeycloakError` on any transport error or non-2xx status.
|
||||||
|
"""
|
||||||
|
url = f"{self._admin_url}{path}"
|
||||||
|
try:
|
||||||
|
resp = self._session.get(
|
||||||
|
url,
|
||||||
|
headers=self._headers(),
|
||||||
|
params=params,
|
||||||
|
timeout=self._timeout,
|
||||||
|
verify=self._verify,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error("Keycloak request failed (GET %s): %s", url, exc)
|
||||||
|
raise KeycloakError("Keycloak request failed") from exc
|
||||||
|
|
||||||
|
if not (200 <= resp.status_code < 300):
|
||||||
|
logger.error(
|
||||||
|
"Keycloak returned %s for GET %s: %s",
|
||||||
|
resp.status_code,
|
||||||
|
url,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
raise KeycloakError("Keycloak request failed")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error("Malformed JSON from Keycloak (GET %s): %s", url, exc)
|
||||||
|
raise KeycloakError("Malformed response from Keycloak") from exc
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_all_users(self) -> list[dict]:
|
||||||
|
"""Return every realm user as a full dict (including ``attributes``).
|
||||||
|
|
||||||
|
Walks ``/users`` page by page using ``first``/``max`` until a short page
|
||||||
|
signals the end. ``briefRepresentation=false`` asks Keycloak to include
|
||||||
|
user attributes in the list response.
|
||||||
|
"""
|
||||||
|
users: list[dict] = []
|
||||||
|
first = 0
|
||||||
|
while True:
|
||||||
|
batch = self._get(
|
||||||
|
"/users",
|
||||||
|
params={
|
||||||
|
"first": first,
|
||||||
|
"max": _PAGE_SIZE,
|
||||||
|
"briefRepresentation": "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(batch, list):
|
||||||
|
logger.error("Unexpected /users payload type: %s", type(batch).__name__)
|
||||||
|
raise KeycloakError("Unexpected response from Keycloak")
|
||||||
|
users.extend(batch)
|
||||||
|
if len(batch) < _PAGE_SIZE:
|
||||||
|
break
|
||||||
|
first += _PAGE_SIZE
|
||||||
|
return users
|
||||||
|
|
||||||
|
def get_all_groups(self) -> list[dict]:
|
||||||
|
"""Return a flattened list of all groups (top-level + every descendant).
|
||||||
|
|
||||||
|
Keycloak's ``/groups`` endpoint returns top-level groups with nested
|
||||||
|
``subGroups``. We recurse through that structure and emit one dict per
|
||||||
|
group. Each returned dict is guaranteed to carry ``id``, ``name`` and
|
||||||
|
``attributes``. The list/tree view often omits ``attributes`` (and may
|
||||||
|
omit ``subGroups`` past a depth), so for any group missing either we
|
||||||
|
fetch its full representation via ``GET /groups/{id}`` and its children
|
||||||
|
via ``GET /groups/{id}/children``.
|
||||||
|
"""
|
||||||
|
top_level = self._get("/groups", params={"first": 0, "max": 1000})
|
||||||
|
if not isinstance(top_level, list):
|
||||||
|
logger.error("Unexpected /groups payload type: %s", type(top_level).__name__)
|
||||||
|
raise KeycloakError("Unexpected response from Keycloak")
|
||||||
|
|
||||||
|
flattened: list[dict] = []
|
||||||
|
for group in top_level:
|
||||||
|
self._collect_group(group, flattened)
|
||||||
|
return flattened
|
||||||
|
|
||||||
|
def _collect_group(self, group: dict, out: list[dict]) -> None:
|
||||||
|
"""Append ``group`` (normalised) and all its descendants to ``out``."""
|
||||||
|
group_id = group.get("id")
|
||||||
|
if not group_id:
|
||||||
|
logger.warning("Skipping group without an id: %r", group)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure attributes are present; the list view commonly omits them, so
|
||||||
|
# fetch the full representation when missing.
|
||||||
|
if "attributes" not in group:
|
||||||
|
full = self._get(f"/groups/{group_id}")
|
||||||
|
attributes = full.get("attributes") or {}
|
||||||
|
name = full.get("name", group.get("name"))
|
||||||
|
source = full
|
||||||
|
else:
|
||||||
|
attributes = group.get("attributes") or {}
|
||||||
|
name = group.get("name")
|
||||||
|
source = group
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": group_id,
|
||||||
|
"name": name,
|
||||||
|
"attributes": attributes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Children: trust an explicit ``subGroups`` key even when it is an empty
|
||||||
|
# list (a leaf group) — only fall back to the dedicated children endpoint
|
||||||
|
# when the key is absent entirely (newer KC builds page subgroups and may
|
||||||
|
# omit them from the tree view).
|
||||||
|
if "subGroups" in source:
|
||||||
|
sub_groups = source.get("subGroups") or []
|
||||||
|
else:
|
||||||
|
sub_groups = self._get(f"/groups/{group_id}/children", params={"first": 0, "max": 1000})
|
||||||
|
|
||||||
|
if isinstance(sub_groups, list):
|
||||||
|
for child in sub_groups:
|
||||||
|
self._collect_group(child, out)
|
||||||
|
|
||||||
|
def get_group_members(self, group_id: str) -> list[dict]:
|
||||||
|
"""Return all members of ``group_id`` as user dicts (paginated)."""
|
||||||
|
members: list[dict] = []
|
||||||
|
first = 0
|
||||||
|
while True:
|
||||||
|
batch = self._get(
|
||||||
|
f"/groups/{group_id}/members",
|
||||||
|
params={
|
||||||
|
"first": first,
|
||||||
|
"max": _PAGE_SIZE,
|
||||||
|
"briefRepresentation": "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(batch, list):
|
||||||
|
logger.error(
|
||||||
|
"Unexpected /groups/%s/members payload type: %s",
|
||||||
|
group_id,
|
||||||
|
type(batch).__name__,
|
||||||
|
)
|
||||||
|
raise KeycloakError("Unexpected response from Keycloak")
|
||||||
|
members.extend(batch)
|
||||||
|
if len(batch) < _PAGE_SIZE:
|
||||||
|
break
|
||||||
|
first += _PAGE_SIZE
|
||||||
|
return members
|
||||||
295
ldap-gateway/ldap_gateway/mapping.py
Normal file
295
ldap-gateway/ldap_gateway/mapping.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"""Pure KC -> LDAP mapping functions.
|
||||||
|
|
||||||
|
No Twisted, no I/O, no network. Every function here is a deterministic pure
|
||||||
|
function of its inputs (plus :class:`~ldap_gateway.config.Config` and the
|
||||||
|
:class:`~ldap_gateway.identity.IdentityResolver`). See ``CONTRACT.md`` (mapping.py)
|
||||||
|
and ``SPEC.md`` (§5 attribute mapping, §6 UID/GID, §9 edge cases).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .identity import IdentityResolver
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RFC 4514 DN-value escaping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Characters that must always be escaped when they appear inside an RDN value.
|
||||||
|
_RFC4514_SPECIALS = set(',+"\\<>;=')
|
||||||
|
|
||||||
|
|
||||||
|
def escape_dn_value(value: str) -> str:
|
||||||
|
"""Escape a string for use as an RFC 4514 RDN attribute value.
|
||||||
|
|
||||||
|
Escapes ``, + " \\ < > ; =`` anywhere in the string, a leading ``#``, and a
|
||||||
|
leading or trailing space. Each escaped character is prefixed with a single
|
||||||
|
backslash. NUL is escaped as ``\\00`` per RFC 4514.
|
||||||
|
"""
|
||||||
|
if value == "":
|
||||||
|
return ""
|
||||||
|
|
||||||
|
chars: list[str] = []
|
||||||
|
last = len(value) - 1
|
||||||
|
for i, ch in enumerate(value):
|
||||||
|
if ch == "\x00":
|
||||||
|
# NUL -> hex escape per RFC 4514.
|
||||||
|
chars.append("\\00")
|
||||||
|
elif ch in _RFC4514_SPECIALS:
|
||||||
|
chars.append("\\" + ch)
|
||||||
|
elif ch == "#" and i == 0:
|
||||||
|
# Leading '#' is special (would otherwise mark a hex-encoded value).
|
||||||
|
chars.append("\\#")
|
||||||
|
elif ch == " " and (i == 0 or i == last):
|
||||||
|
# Leading / trailing space.
|
||||||
|
chars.append("\\ ")
|
||||||
|
else:
|
||||||
|
chars.append(ch)
|
||||||
|
return "".join(chars)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DN builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def user_dn(rdn_value: str, config: Config, resolver: IdentityResolver) -> str:
|
||||||
|
"""DN for a user entry: ``<rdn_attr>=<escaped value>,ou=people,<base_dn>``."""
|
||||||
|
return f"{resolver.rdn_attr}={escape_dn_value(rdn_value)},{people_ou_dn(config)}"
|
||||||
|
|
||||||
|
|
||||||
|
def group_dn(group_name: str, config: Config) -> str:
|
||||||
|
"""DN for a group entry: ``cn=<escaped name>,ou=groups,<base_dn>``."""
|
||||||
|
return f"cn={escape_dn_value(group_name)},{groups_ou_dn(config)}"
|
||||||
|
|
||||||
|
|
||||||
|
def people_ou_dn(config: Config) -> str:
|
||||||
|
"""DN of the people organizational unit."""
|
||||||
|
return f"ou=people,{config.base_dn}"
|
||||||
|
|
||||||
|
|
||||||
|
def groups_ou_dn(config: Config) -> str:
|
||||||
|
"""DN of the groups organizational unit."""
|
||||||
|
return f"ou=groups,{config.base_dn}"
|
||||||
|
|
||||||
|
|
||||||
|
def services_ou_dn(config: Config) -> str:
|
||||||
|
"""DN of the services (bind accounts) organizational unit."""
|
||||||
|
return f"ou=services,{config.base_dn}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POSIX UID/GID allocation (SPEC §6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _first_attr_value(kc_obj: dict, name: str) -> str | None:
|
||||||
|
"""Return the first value of a KC ``attributes[name]`` list, or None."""
|
||||||
|
attributes = kc_obj.get("attributes") or {}
|
||||||
|
values = attributes.get(name)
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
first = values[0]
|
||||||
|
if first is None:
|
||||||
|
return None
|
||||||
|
text = str(first).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_posix_number(kc_obj: dict, minimum: int, maximum: int) -> int:
|
||||||
|
"""Deterministically derive a POSIX number from the stable KC UUID.
|
||||||
|
|
||||||
|
``minimum + int(sha1(id).hexdigest(), 16) % (maximum - minimum)``. Keyed on
|
||||||
|
the immutable ``id`` UUID so the number is stable across renames.
|
||||||
|
"""
|
||||||
|
kc_id = kc_obj.get("id")
|
||||||
|
span = maximum - minimum
|
||||||
|
if not kc_id:
|
||||||
|
# CONTRACT-NOTE: KC objects are guaranteed an "id" by the Admin REST
|
||||||
|
# shape; if absent we cannot derive a stable number. Fall back to the
|
||||||
|
# range minimum and log rather than crashing the tree build.
|
||||||
|
logger.warning("KC object missing 'id'; cannot derive POSIX number, using minimum")
|
||||||
|
return minimum
|
||||||
|
if span <= 0:
|
||||||
|
# CONTRACT-NOTE: a misconfigured range (max <= min) would make the modulo
|
||||||
|
# invalid. Clamp to the minimum and log instead of raising.
|
||||||
|
logger.warning("Invalid POSIX range (min=%s, max=%s); using minimum", minimum, maximum)
|
||||||
|
return minimum
|
||||||
|
digest = int(hashlib.sha1(str(kc_id).encode("utf-8")).hexdigest(), 16)
|
||||||
|
return minimum + (digest % span)
|
||||||
|
|
||||||
|
|
||||||
|
def uid_number(kc_user: dict, config: Config) -> int:
|
||||||
|
"""POSIX uidNumber: KC ``uidNumber`` attribute wins, else derived (SPEC §6)."""
|
||||||
|
pinned = _first_attr_value(kc_user, "uidNumber")
|
||||||
|
if pinned is not None:
|
||||||
|
try:
|
||||||
|
return int(pinned)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("KC uidNumber attribute %r is not an integer; deriving instead", pinned)
|
||||||
|
return _derive_posix_number(kc_user, config.uid_min, config.uid_max)
|
||||||
|
|
||||||
|
|
||||||
|
def gid_number(kc_group_or_user: dict, config: Config) -> int:
|
||||||
|
"""POSIX gidNumber: KC ``gidNumber`` attribute wins, else derived (SPEC §6)."""
|
||||||
|
pinned = _first_attr_value(kc_group_or_user, "gidNumber")
|
||||||
|
if pinned is not None:
|
||||||
|
try:
|
||||||
|
return int(pinned)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("KC gidNumber attribute %r is not an integer; deriving instead", pinned)
|
||||||
|
return _derive_posix_number(kc_group_or_user, config.gid_min, config.gid_max)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inclusion / exclusion filters (SPEC §9)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def should_include_user(kc_user: dict, config: Config) -> bool:
|
||||||
|
"""Whether a KC user should appear under ``ou=people``.
|
||||||
|
|
||||||
|
Excludes disabled users (when ``exclude_disabled``) and service-account
|
||||||
|
users — username starting ``service-account-`` or ending ``.svc`` — when
|
||||||
|
``exclude_service_accounts``.
|
||||||
|
"""
|
||||||
|
if config.exclude_disabled and not kc_user.get("enabled", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if config.exclude_service_accounts:
|
||||||
|
username = kc_user.get("username") or ""
|
||||||
|
if username.startswith("service-account-") or username.endswith(".svc"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def should_include_group(kc_group: dict, config: Config) -> bool:
|
||||||
|
"""Whether a KC group should appear under ``ou=groups``.
|
||||||
|
|
||||||
|
``group_include`` (if non-empty) requires the name to match at least one
|
||||||
|
glob; ``group_exclude`` then drops any matching name.
|
||||||
|
"""
|
||||||
|
name = kc_group.get("name") or ""
|
||||||
|
|
||||||
|
if config.group_include:
|
||||||
|
if not any(fnmatch(name, pattern) for pattern in config.group_include):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if config.group_exclude:
|
||||||
|
if any(fnmatch(name, pattern) for pattern in config.group_exclude):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry attribute builders (SPEC §5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_USER_OBJECT_CLASSES = [
|
||||||
|
"top",
|
||||||
|
"person",
|
||||||
|
"organizationalPerson",
|
||||||
|
"inetOrgPerson",
|
||||||
|
"posixAccount",
|
||||||
|
"shadowAccount",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def user_entry_attributes(
|
||||||
|
kc_user: dict,
|
||||||
|
rdn_value: str,
|
||||||
|
member_group_dns: list[str],
|
||||||
|
config: Config,
|
||||||
|
resolver: IdentityResolver,
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""Build the LDAP attribute dict for a user entry (SPEC §5.1).
|
||||||
|
|
||||||
|
All values are ``list[str]``. Attributes whose KC source is missing/empty are
|
||||||
|
omitted (no empty ``givenName`` etc.). ``userPassword`` is NEVER included.
|
||||||
|
"""
|
||||||
|
username = (kc_user.get("username") or "").strip()
|
||||||
|
first_name = (kc_user.get("firstName") or "").strip()
|
||||||
|
last_name = (kc_user.get("lastName") or "").strip()
|
||||||
|
email = (kc_user.get("email") or "").strip()
|
||||||
|
kc_id = kc_user.get("id")
|
||||||
|
|
||||||
|
full_name = " ".join(part for part in (first_name, last_name) if part).strip()
|
||||||
|
# cn falls back to username when no first/last name is present (SPEC §5.1).
|
||||||
|
cn = full_name or username
|
||||||
|
|
||||||
|
attrs: dict[str, list[str]] = {"objectClass": list(_USER_OBJECT_CLASSES)}
|
||||||
|
|
||||||
|
# RDN attribute (e.g. uid or cn) carries the resolver-driven RDN value.
|
||||||
|
if rdn_value:
|
||||||
|
attrs[resolver.rdn_attr] = [rdn_value]
|
||||||
|
|
||||||
|
if username:
|
||||||
|
attrs["uid"] = [username]
|
||||||
|
if cn:
|
||||||
|
attrs["cn"] = [cn]
|
||||||
|
if last_name:
|
||||||
|
attrs["sn"] = [last_name]
|
||||||
|
if first_name:
|
||||||
|
attrs["givenName"] = [first_name]
|
||||||
|
if email:
|
||||||
|
attrs["mail"] = [email]
|
||||||
|
if full_name:
|
||||||
|
attrs["displayName"] = [full_name]
|
||||||
|
if kc_id:
|
||||||
|
attrs["entryUUID"] = [str(kc_id)]
|
||||||
|
|
||||||
|
attrs["uidNumber"] = [str(uid_number(kc_user, config))]
|
||||||
|
attrs["gidNumber"] = [str(gid_number(kc_user, config))]
|
||||||
|
|
||||||
|
if username:
|
||||||
|
attrs["homeDirectory"] = [f"{config.home_base}/{username}"]
|
||||||
|
attrs["loginShell"] = [config.default_shell]
|
||||||
|
|
||||||
|
if member_group_dns:
|
||||||
|
attrs["memberOf"] = list(member_group_dns)
|
||||||
|
|
||||||
|
# userPassword is intentionally never emitted (SPEC §8) — the gateway serves
|
||||||
|
# no credential material.
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
def group_entry_attributes(
|
||||||
|
kc_group: dict,
|
||||||
|
member_rdn_values: list[str],
|
||||||
|
member_dns: list[str],
|
||||||
|
config: Config,
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""Build the LDAP attribute dict for a group entry (SPEC §5.2, §9 empty groups).
|
||||||
|
|
||||||
|
Primary objectClass is ``posixGroup``; ``groupOfNames`` (and the ``member``
|
||||||
|
attribute) are added only when the group has at least one member DN, since
|
||||||
|
``groupOfNames`` requires a non-empty ``member``.
|
||||||
|
"""
|
||||||
|
name = (kc_group.get("name") or "").strip()
|
||||||
|
|
||||||
|
object_classes = ["top", "posixGroup"]
|
||||||
|
if member_dns:
|
||||||
|
object_classes.append("groupOfNames")
|
||||||
|
|
||||||
|
attrs: dict[str, list[str]] = {"objectClass": object_classes}
|
||||||
|
|
||||||
|
if name:
|
||||||
|
attrs["cn"] = [name]
|
||||||
|
|
||||||
|
attrs["gidNumber"] = [str(gid_number(kc_group, config))]
|
||||||
|
|
||||||
|
if member_rdn_values:
|
||||||
|
attrs["memberUid"] = list(member_rdn_values)
|
||||||
|
if member_dns:
|
||||||
|
attrs["member"] = list(member_dns)
|
||||||
|
|
||||||
|
return attrs
|
||||||
115
ldap-gateway/ldap_gateway/server.py
Normal file
115
ldap-gateway/ldap_gateway/server.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Process entry point: wire the components together and run the Twisted reactor.
|
||||||
|
|
||||||
|
Composition (see ``CONTRACT.md`` "server.py" and ``SPEC.md`` §3/§10):
|
||||||
|
|
||||||
|
* load config -> identity resolver -> KC client -> cache -> directory holder
|
||||||
|
* perform an initial (best-effort) cache warm and swap it into the holder
|
||||||
|
* stand up the LDAP listener (plain/StartTLS) and, when TLS material is
|
||||||
|
configured, the LDAPS listener
|
||||||
|
* schedule a background ``LoopingCall`` that refreshes the cache off the
|
||||||
|
reactor thread (``deferToThread``) and atomically swaps the new snapshot in
|
||||||
|
* run the reactor
|
||||||
|
|
||||||
|
The server is deliberately resilient at startup: if the initial Keycloak warm
|
||||||
|
fails, we log and continue serving the empty-but-valid tree so the process
|
||||||
|
still becomes reachable (readiness can gate on the first successful warm).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from twisted.internet import reactor, ssl
|
||||||
|
from twisted.internet.task import LoopingCall
|
||||||
|
from twisted.internet.threads import deferToThread
|
||||||
|
|
||||||
|
from .cache import DirectorySnapshot, KeycloakCache
|
||||||
|
from .config import load_config
|
||||||
|
from .directory import DirectoryHolder
|
||||||
|
from .handlers import LDAPServerFactory
|
||||||
|
from .identity import get_identity_resolver
|
||||||
|
from .kc_client import KeycloakClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_logging() -> None:
|
||||||
|
"""Configure root logging from the ``LOG_LEVEL`` env var (default ``INFO``)."""
|
||||||
|
level_name = os.environ.get("LOG_LEVEL", "INFO").strip().upper() or "INFO"
|
||||||
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_refresh_error(failure: object) -> None:
|
||||||
|
"""Errback for the background refresh: log and swallow so the loop continues.
|
||||||
|
|
||||||
|
A failed background refresh must never tear down the ``LoopingCall`` or the
|
||||||
|
reactor — the cache keeps serving the last-good snapshot until the next tick.
|
||||||
|
"""
|
||||||
|
logger.error("Background Keycloak refresh failed: %s", failure)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Build the gateway, register listeners + refresh loop, and run the reactor."""
|
||||||
|
_configure_logging()
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
client = KeycloakClient(config)
|
||||||
|
cache = KeycloakCache(client, config)
|
||||||
|
holder = DirectoryHolder(config, resolver)
|
||||||
|
|
||||||
|
# Initial warm. Best-effort: on failure the holder keeps its empty-but-valid
|
||||||
|
# tree and the server still starts (searches return zero results until a
|
||||||
|
# later refresh succeeds).
|
||||||
|
try:
|
||||||
|
snapshot: DirectorySnapshot = cache.refresh()
|
||||||
|
holder.swap(snapshot)
|
||||||
|
logger.info("Initial Keycloak cache warm succeeded")
|
||||||
|
except Exception: # noqa: BLE001 - never let a warm failure stop startup
|
||||||
|
logger.error(
|
||||||
|
"Initial Keycloak cache warm failed; starting with empty directory tree",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
factory = LDAPServerFactory(holder, config)
|
||||||
|
|
||||||
|
# Plain/StartTLS listener. StartTLS is negotiated on the plain port by the
|
||||||
|
# LDAP server protocol; we always stand this up.
|
||||||
|
reactor.listenTCP(config.port, factory, interface=config.bind_host)
|
||||||
|
logger.info("Listening for LDAP (plain/StartTLS) on %s:%d", config.bind_host, config.port)
|
||||||
|
|
||||||
|
# LDAPS listener, only when both cert and key are configured.
|
||||||
|
if config.tls_cert and config.tls_key:
|
||||||
|
context_factory = ssl.DefaultOpenSSLContextFactory(config.tls_key, config.tls_cert)
|
||||||
|
reactor.listenSSL(
|
||||||
|
config.ldaps_port,
|
||||||
|
factory,
|
||||||
|
context_factory,
|
||||||
|
interface=config.bind_host,
|
||||||
|
)
|
||||||
|
logger.info("Listening for LDAPS on %s:%d", config.bind_host, config.ldaps_port)
|
||||||
|
else:
|
||||||
|
logger.info("LDAPS disabled (LDAP_TLS_CERT/LDAP_TLS_KEY not both set); plain/StartTLS only")
|
||||||
|
|
||||||
|
# Background refresh loop: run the blocking cache fetch in a worker thread,
|
||||||
|
# then swap the resulting snapshot into the holder on the reactor thread.
|
||||||
|
refresh_loop = LoopingCall(
|
||||||
|
lambda: deferToThread(cache.refresh).addCallbacks(holder.swap, _log_refresh_error)
|
||||||
|
)
|
||||||
|
refresh_loop.start(config.refresh_interval, now=False)
|
||||||
|
logger.info(
|
||||||
|
"Scheduled background Keycloak refresh every %d seconds",
|
||||||
|
config.refresh_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Starting reactor")
|
||||||
|
reactor.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
32
ldap-gateway/pyproject.toml
Normal file
32
ldap-gateway/pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "ldap_gateway"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "LDAP v3 gateway exposing Keycloak users and groups (read-only) for legacy LDAP consumers."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"twisted",
|
||||||
|
"ldaptor",
|
||||||
|
"requests",
|
||||||
|
"pyopenssl",
|
||||||
|
"service_identity",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"ruff",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["ldap_gateway*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
30
ldap-gateway/run-local.sh
Executable file
30
ldap-gateway/run-local.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the LDAP gateway locally against the ./dev.sh Keycloak (http://localhost:8180).
|
||||||
|
# Usage: ./run-local.sh (foreground; Ctrl-C to stop)
|
||||||
|
# Reads the KC service-account secret from ../.keycloak-secret (created by dev.sh).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
if [ ! -f "$REPO_ROOT/.keycloak-secret" ]; then
|
||||||
|
echo "ERROR: $REPO_ROOT/.keycloak-secret not found — start dev.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8180}"
|
||||||
|
export KEYCLOAK_REALM="${KEYCLOAK_REALM:-mgmt}"
|
||||||
|
export KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-mgmt-admin-client}"
|
||||||
|
export KEYCLOAK_CLIENT_SECRET="$(cat "$REPO_ROOT/.keycloak-secret")"
|
||||||
|
export KEYCLOAK_VERIFY_SSL=false
|
||||||
|
|
||||||
|
export LDAP_BASE_DN="${LDAP_BASE_DN:-dc=mgmt,dc=example,dc=com}"
|
||||||
|
export LDAP_BIND_HOST="${LDAP_BIND_HOST:-127.0.0.1}"
|
||||||
|
export LDAP_PORT="${LDAP_PORT:-1389}" # non-privileged; 389 needs root
|
||||||
|
export LDAP_ALLOW_ANON="${LDAP_ALLOW_ANON:-false}"
|
||||||
|
# One demo service-bind account. Override LDAP_SERVICE_ACCOUNTS to change.
|
||||||
|
export LDAP_SERVICE_ACCOUNTS="${LDAP_SERVICE_ACCOUNTS:-{\"cn=svc,ou=services,dc=mgmt,dc=example,dc=com\":\"svcpass\"}}"
|
||||||
|
export LDAP_CACHE_TTL="${LDAP_CACHE_TTL:-60}"
|
||||||
|
export LOG_LEVEL="${LOG_LEVEL:-INFO}"
|
||||||
|
|
||||||
|
exec "$SCRIPT_DIR/.venv/bin/python" -m ldap_gateway.server
|
||||||
0
ldap-gateway/tests/__init__.py
Normal file
0
ldap-gateway/tests/__init__.py
Normal file
67
ldap-gateway/tests/conftest.py
Normal file
67
ldap-gateway/tests/conftest.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Shared test fixtures/factories for the LDAP gateway test suite.
|
||||||
|
|
||||||
|
``make_config`` builds a fully valid :class:`~ldap_gateway.config.Config` directly (no
|
||||||
|
env parsing) so other modules' tests get a sensible default Config with all fields set.
|
||||||
|
Pass keyword overrides for the fields you care about.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ldap_gateway.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(**overrides: Any) -> Config:
|
||||||
|
"""Construct a valid :class:`Config` with sensible defaults, applying ``overrides``."""
|
||||||
|
defaults: dict[str, Any] = dict(
|
||||||
|
# Keycloak
|
||||||
|
keycloak_url="https://kc.example.com",
|
||||||
|
keycloak_realm="mgmt",
|
||||||
|
keycloak_client_id="ldap-gateway",
|
||||||
|
keycloak_client_secret="s3cret",
|
||||||
|
keycloak_verify_ssl=True,
|
||||||
|
keycloak_timeout=30,
|
||||||
|
# LDAP server
|
||||||
|
base_dn="dc=mgmt,dc=example,dc=com",
|
||||||
|
bind_host="0.0.0.0",
|
||||||
|
port=389,
|
||||||
|
ldaps_port=636,
|
||||||
|
tls_cert=None,
|
||||||
|
tls_key=None,
|
||||||
|
allow_anon=False,
|
||||||
|
service_accounts={},
|
||||||
|
# Identity
|
||||||
|
identity_source="username",
|
||||||
|
cert_cn_attribute="certCN",
|
||||||
|
# POSIX
|
||||||
|
uid_min=100000,
|
||||||
|
uid_max=599999,
|
||||||
|
gid_min=100000,
|
||||||
|
gid_max=599999,
|
||||||
|
default_shell="/bin/bash",
|
||||||
|
home_base="/home",
|
||||||
|
# Caching / filtering
|
||||||
|
cache_ttl=60,
|
||||||
|
refresh_interval=60,
|
||||||
|
exclude_disabled=True,
|
||||||
|
exclude_service_accounts=True,
|
||||||
|
group_include=[],
|
||||||
|
group_exclude=[],
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return Config(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_factory():
|
||||||
|
"""Fixture returning the :func:`make_config` factory."""
|
||||||
|
return make_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config() -> Config:
|
||||||
|
"""A ready-to-use default Config."""
|
||||||
|
return make_config()
|
||||||
62
ldap-gateway/tests/test_bind.py
Normal file
62
ldap-gateway/tests/test_bind.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for ldap_gateway.bind.validate_service_bind."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ldap_gateway.bind import validate_service_bind
|
||||||
|
|
||||||
|
from .conftest import make_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_returns_true() -> None:
|
||||||
|
config = make_config(
|
||||||
|
service_accounts={"cn=svc,ou=services,dc=mgmt,dc=example,dc=com": "s3cret"}
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
validate_service_bind("cn=svc,ou=services,dc=mgmt,dc=example,dc=com", b"s3cret", config)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_returns_false() -> None:
|
||||||
|
config = make_config(
|
||||||
|
service_accounts={"cn=svc,ou=services,dc=mgmt,dc=example,dc=com": "s3cret"}
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
validate_service_bind("cn=svc,ou=services,dc=mgmt,dc=example,dc=com", b"wrong", config)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_dn_returns_false() -> None:
|
||||||
|
config = make_config(
|
||||||
|
service_accounts={"cn=svc,ou=services,dc=mgmt,dc=example,dc=com": "s3cret"}
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
validate_service_bind("cn=nope,ou=services,dc=mgmt,dc=example,dc=com", b"s3cret", config)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_dn_with_no_accounts_returns_false() -> None:
|
||||||
|
config = make_config(service_accounts={})
|
||||||
|
assert validate_service_bind("cn=svc,ou=services,dc=x", b"pw", config) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dn_lookup_is_case_insensitive() -> None:
|
||||||
|
# config stores keys lowercased; an upper/mixed-case bind DN must still match.
|
||||||
|
config = make_config(
|
||||||
|
service_accounts={"cn=svc,ou=services,dc=mgmt,dc=example,dc=com": "s3cret"}
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
validate_service_bind("CN=SVC,OU=Services,DC=Mgmt,DC=Example,DC=Com", b"s3cret", config)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_password_against_real_account_returns_false() -> None:
|
||||||
|
config = make_config(
|
||||||
|
service_accounts={"cn=svc,ou=services,dc=mgmt,dc=example,dc=com": "s3cret"}
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
validate_service_bind("cn=svc,ou=services,dc=mgmt,dc=example,dc=com", b"", config) is False
|
||||||
|
)
|
||||||
228
ldap-gateway/tests/test_cache.py
Normal file
228
ldap-gateway/tests/test_cache.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""Tests for :mod:`ldap_gateway.cache`.
|
||||||
|
|
||||||
|
Uses an in-process fake Keycloak client (no network) implementing the three
|
||||||
|
methods :class:`ldap_gateway.cache.KeycloakCache` depends on:
|
||||||
|
``get_all_users``, ``get_all_groups``, ``get_group_members``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ldap_gateway.cache import DirectorySnapshot, KeycloakCache
|
||||||
|
from ldap_gateway.kc_client import KeycloakError
|
||||||
|
|
||||||
|
from .conftest import make_config
|
||||||
|
|
||||||
|
|
||||||
|
# --- fakes -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeKeycloakClient:
|
||||||
|
"""A scripted, call-counting stand-in for ``KeycloakClient``.
|
||||||
|
|
||||||
|
By default returns a small two-user / two-group directory. ``fail`` flips
|
||||||
|
every method to raise :class:`KeycloakError`. ``barrier``/``delay`` let a
|
||||||
|
test force overlapping ``refresh()`` calls into the critical section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
users: list[dict] | None = None,
|
||||||
|
groups: list[dict] | None = None,
|
||||||
|
members: dict[str, list[dict]] | None = None,
|
||||||
|
fail: bool = False,
|
||||||
|
on_fetch: "threading.Event | None" = None,
|
||||||
|
release_fetch: "threading.Event | None" = None,
|
||||||
|
) -> None:
|
||||||
|
self._users = users if users is not None else _DEFAULT_USERS
|
||||||
|
self._groups = groups if groups is not None else _DEFAULT_GROUPS
|
||||||
|
self._members = members if members is not None else _DEFAULT_MEMBERS
|
||||||
|
self.fail = fail
|
||||||
|
# Synchronization hooks for the single-flight test.
|
||||||
|
self._on_fetch = on_fetch
|
||||||
|
self._release_fetch = release_fetch
|
||||||
|
# Call counters.
|
||||||
|
self.users_calls = 0
|
||||||
|
self.groups_calls = 0
|
||||||
|
self.member_calls: list[str] = []
|
||||||
|
|
||||||
|
def get_all_users(self) -> list[dict]:
|
||||||
|
self.users_calls += 1
|
||||||
|
if self._on_fetch is not None:
|
||||||
|
self._on_fetch.set()
|
||||||
|
if self._release_fetch is not None:
|
||||||
|
# Block until the test allows the fetch to proceed, simulating a
|
||||||
|
# slow KC enumeration so a second refresh() can pile up behind us.
|
||||||
|
self._release_fetch.wait(timeout=5)
|
||||||
|
if self.fail:
|
||||||
|
raise KeycloakError("simulated KC failure")
|
||||||
|
return list(self._users)
|
||||||
|
|
||||||
|
def get_all_groups(self) -> list[dict]:
|
||||||
|
self.groups_calls += 1
|
||||||
|
if self.fail:
|
||||||
|
raise KeycloakError("simulated KC failure")
|
||||||
|
return list(self._groups)
|
||||||
|
|
||||||
|
def get_group_members(self, group_id: str) -> list[dict]:
|
||||||
|
self.member_calls.append(group_id)
|
||||||
|
if self.fail:
|
||||||
|
raise KeycloakError("simulated KC failure")
|
||||||
|
return list(self._members.get(group_id, []))
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_USERS = [
|
||||||
|
{"id": "u1", "username": "alice", "enabled": True, "attributes": {}},
|
||||||
|
{"id": "u2", "username": "bob", "enabled": True, "attributes": {}},
|
||||||
|
]
|
||||||
|
_DEFAULT_GROUPS = [
|
||||||
|
{"id": "g1", "name": "ACME", "attributes": {}},
|
||||||
|
{"id": "g2", "name": "ACME-bitbucket-read", "attributes": {}},
|
||||||
|
]
|
||||||
|
_DEFAULT_MEMBERS = {
|
||||||
|
"g1": [_DEFAULT_USERS[0], _DEFAULT_USERS[1]],
|
||||||
|
"g2": [_DEFAULT_USERS[0]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- fixtures ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config():
|
||||||
|
return make_config()
|
||||||
|
|
||||||
|
|
||||||
|
# --- tests -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_builds_correct_snapshot(config):
|
||||||
|
client = FakeKeycloakClient()
|
||||||
|
cache = KeycloakCache(client, config)
|
||||||
|
|
||||||
|
snapshot = cache.refresh()
|
||||||
|
|
||||||
|
assert isinstance(snapshot, DirectorySnapshot)
|
||||||
|
assert snapshot.users == _DEFAULT_USERS
|
||||||
|
assert snapshot.groups == _DEFAULT_GROUPS
|
||||||
|
# members_by_group keyed by group id, one fetch per group.
|
||||||
|
assert set(snapshot.members_by_group) == {"g1", "g2"}
|
||||||
|
assert snapshot.members_by_group["g1"] == _DEFAULT_MEMBERS["g1"]
|
||||||
|
assert snapshot.members_by_group["g2"] == _DEFAULT_MEMBERS["g2"]
|
||||||
|
# One enumeration of users + groups, one member fetch per group.
|
||||||
|
assert client.users_calls == 1
|
||||||
|
assert client.groups_calls == 1
|
||||||
|
assert client.member_calls == ["g1", "g2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_is_frozen():
|
||||||
|
snapshot = DirectorySnapshot(users=[], groups=[], members_by_group={})
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
snapshot.users = [] # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_is_none_before_refresh(config):
|
||||||
|
client = FakeKeycloakClient()
|
||||||
|
cache = KeycloakCache(client, config)
|
||||||
|
|
||||||
|
assert cache.current() is None
|
||||||
|
# current() must not trigger a fetch.
|
||||||
|
assert client.users_calls == 0
|
||||||
|
assert client.groups_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_returns_snapshot_after_refresh(config):
|
||||||
|
client = FakeKeycloakClient()
|
||||||
|
cache = KeycloakCache(client, config)
|
||||||
|
|
||||||
|
produced = cache.refresh()
|
||||||
|
|
||||||
|
assert cache.current() is produced
|
||||||
|
|
||||||
|
|
||||||
|
def test_keycloak_error_after_good_refresh_returns_last_good(config):
|
||||||
|
client = FakeKeycloakClient()
|
||||||
|
cache = KeycloakCache(client, config)
|
||||||
|
|
||||||
|
good = cache.refresh()
|
||||||
|
assert cache.current() is good
|
||||||
|
|
||||||
|
# Now make the client fail and refresh again.
|
||||||
|
client.fail = True
|
||||||
|
result = cache.refresh()
|
||||||
|
|
||||||
|
# Last-good snapshot is returned and remains current.
|
||||||
|
assert result is good
|
||||||
|
assert cache.current() is good
|
||||||
|
|
||||||
|
|
||||||
|
def test_keycloak_error_with_no_prior_snapshot_reraises(config):
|
||||||
|
client = FakeKeycloakClient(fail=True)
|
||||||
|
cache = KeycloakCache(client, config)
|
||||||
|
|
||||||
|
with pytest.raises(KeycloakError):
|
||||||
|
cache.refresh()
|
||||||
|
|
||||||
|
# Still nothing cached.
|
||||||
|
assert cache.current() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_flight_coalesces_concurrent_refresh(config):
|
||||||
|
"""Two concurrent refresh() calls must result in exactly one KC fetch."""
|
||||||
|
fetch_started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
client = FakeKeycloakClient(on_fetch=fetch_started, release_fetch=release)
|
||||||
|
cache = KeycloakCache(client, config)
|
||||||
|
|
||||||
|
results: dict[str, DirectorySnapshot] = {}
|
||||||
|
|
||||||
|
def first() -> None:
|
||||||
|
results["first"] = cache.refresh()
|
||||||
|
|
||||||
|
def second() -> None:
|
||||||
|
results["second"] = cache.refresh()
|
||||||
|
|
||||||
|
t1 = threading.Thread(target=first)
|
||||||
|
t1.start()
|
||||||
|
|
||||||
|
# Wait until the first refresh is inside the (blocked) KC fetch, holding
|
||||||
|
# the lock, so the second thread is guaranteed to queue behind it.
|
||||||
|
assert fetch_started.wait(timeout=5)
|
||||||
|
|
||||||
|
t2 = threading.Thread(target=second)
|
||||||
|
t2.start()
|
||||||
|
|
||||||
|
# Give the second thread time to block on the lock, then let the fetch go.
|
||||||
|
# (No sleep needed for correctness; the lock guarantees ordering.)
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
t1.join(timeout=5)
|
||||||
|
t2.join(timeout=5)
|
||||||
|
assert not t1.is_alive()
|
||||||
|
assert not t2.is_alive()
|
||||||
|
|
||||||
|
# Exactly one full enumeration despite two refresh() calls.
|
||||||
|
assert client.users_calls == 1
|
||||||
|
assert client.groups_calls == 1
|
||||||
|
assert client.member_calls == ["g1", "g2"]
|
||||||
|
# Both callers see the same coalesced snapshot.
|
||||||
|
assert results["first"] is results["second"]
|
||||||
|
assert cache.current() is results["first"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sequential_refresh_fetches_again(config):
|
||||||
|
"""Non-overlapping refreshes are *not* coalesced (normal TTL refresh)."""
|
||||||
|
client = FakeKeycloakClient()
|
||||||
|
cache = KeycloakCache(client, config)
|
||||||
|
|
||||||
|
first = cache.refresh()
|
||||||
|
second = cache.refresh()
|
||||||
|
|
||||||
|
assert client.users_calls == 2
|
||||||
|
assert client.groups_calls == 2
|
||||||
|
# A new snapshot object is produced on the second, non-coalesced refresh.
|
||||||
|
assert first is not second
|
||||||
|
assert cache.current() is second
|
||||||
143
ldap-gateway/tests/test_config.py
Normal file
143
ldap-gateway/tests/test_config.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Tests for ldap_gateway.config."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ldap_gateway.config import Config, load_config
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_env(**extra: str) -> dict[str, str]:
|
||||||
|
env = {
|
||||||
|
"KEYCLOAK_URL": "https://kc.example.com",
|
||||||
|
"LDAP_BASE_DN": "dc=mgmt,dc=example,dc=com",
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def test_loads_with_minimal_required_vars() -> None:
|
||||||
|
cfg = load_config(_minimal_env())
|
||||||
|
assert isinstance(cfg, Config)
|
||||||
|
assert cfg.keycloak_url == "https://kc.example.com"
|
||||||
|
assert cfg.base_dn == "dc=mgmt,dc=example,dc=com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_applied() -> None:
|
||||||
|
cfg = load_config(_minimal_env())
|
||||||
|
assert cfg.keycloak_realm == "mgmt"
|
||||||
|
assert cfg.keycloak_verify_ssl is True
|
||||||
|
assert cfg.keycloak_timeout == 30
|
||||||
|
assert cfg.bind_host == "0.0.0.0"
|
||||||
|
assert cfg.port == 389
|
||||||
|
assert cfg.ldaps_port == 636
|
||||||
|
assert cfg.tls_cert is None
|
||||||
|
assert cfg.tls_key is None
|
||||||
|
assert cfg.allow_anon is False
|
||||||
|
assert cfg.service_accounts == {}
|
||||||
|
assert cfg.identity_source == "username"
|
||||||
|
assert cfg.cert_cn_attribute == "certCN"
|
||||||
|
assert cfg.uid_min == 100000
|
||||||
|
assert cfg.uid_max == 599999
|
||||||
|
assert cfg.gid_min == 100000
|
||||||
|
assert cfg.gid_max == 599999
|
||||||
|
assert cfg.default_shell == "/bin/bash"
|
||||||
|
assert cfg.home_base == "/home"
|
||||||
|
assert cfg.cache_ttl == 60
|
||||||
|
assert cfg.exclude_disabled is True
|
||||||
|
assert cfg.exclude_service_accounts is True
|
||||||
|
assert cfg.group_include == []
|
||||||
|
assert cfg.group_exclude == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_interval_defaults_to_cache_ttl() -> None:
|
||||||
|
cfg = load_config(_minimal_env(LDAP_CACHE_TTL="120"))
|
||||||
|
assert cfg.cache_ttl == 120
|
||||||
|
assert cfg.refresh_interval == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_interval_explicit_overrides_cache_ttl() -> None:
|
||||||
|
cfg = load_config(_minimal_env(LDAP_CACHE_TTL="120", LDAP_REFRESH_INTERVAL="15"))
|
||||||
|
assert cfg.cache_ttl == 120
|
||||||
|
assert cfg.refresh_interval == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_keycloak_url_raises() -> None:
|
||||||
|
env = {"LDAP_BASE_DN": "dc=mgmt,dc=example,dc=com"}
|
||||||
|
with pytest.raises(ValueError, match="KEYCLOAK_URL"):
|
||||||
|
load_config(env)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_base_dn_raises() -> None:
|
||||||
|
env = {"KEYCLOAK_URL": "https://kc.example.com"}
|
||||||
|
with pytest.raises(ValueError, match="LDAP_BASE_DN"):
|
||||||
|
load_config(env)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_required_var_treated_as_missing() -> None:
|
||||||
|
with pytest.raises(ValueError, match="KEYCLOAK_URL"):
|
||||||
|
load_config({"KEYCLOAK_URL": " ", "LDAP_BASE_DN": "dc=x"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_accounts_parsed_and_lowercased() -> None:
|
||||||
|
env = _minimal_env(
|
||||||
|
LDAP_SERVICE_ACCOUNTS='{"CN=Svc,OU=services,DC=x": "pw1", "cn=other,ou=services,dc=x": "pw2"}'
|
||||||
|
)
|
||||||
|
cfg = load_config(env)
|
||||||
|
assert cfg.service_accounts == {
|
||||||
|
"cn=svc,ou=services,dc=x": "pw1",
|
||||||
|
"cn=other,ou=services,dc=x": "pw2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_accounts_empty_when_unset() -> None:
|
||||||
|
cfg = load_config(_minimal_env())
|
||||||
|
assert cfg.service_accounts == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_accounts_invalid_json_raises() -> None:
|
||||||
|
with pytest.raises(ValueError, match="LDAP_SERVICE_ACCOUNTS"):
|
||||||
|
load_config(_minimal_env(LDAP_SERVICE_ACCOUNTS="not json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_accounts_non_object_raises() -> None:
|
||||||
|
with pytest.raises(ValueError, match="LDAP_SERVICE_ACCOUNTS"):
|
||||||
|
load_config(_minimal_env(LDAP_SERVICE_ACCOUNTS='["a", "b"]'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_bool_parsing_variants() -> None:
|
||||||
|
cfg = load_config(_minimal_env(KEYCLOAK_VERIFY_SSL="false", LDAP_ALLOW_ANON="yes"))
|
||||||
|
assert cfg.keycloak_verify_ssl is False
|
||||||
|
assert cfg.allow_anon is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_bool_raises() -> None:
|
||||||
|
with pytest.raises(ValueError, match="KEYCLOAK_VERIFY_SSL"):
|
||||||
|
load_config(_minimal_env(KEYCLOAK_VERIFY_SSL="maybe"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_int_raises() -> None:
|
||||||
|
with pytest.raises(ValueError, match="LDAP_PORT"):
|
||||||
|
load_config(_minimal_env(LDAP_PORT="notanumber"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_include_exclude_parsed_as_list() -> None:
|
||||||
|
cfg = load_config(
|
||||||
|
_minimal_env(LDAP_GROUP_INCLUDE="ACME-*, FOO-*", LDAP_GROUP_EXCLUDE="*-internal")
|
||||||
|
)
|
||||||
|
assert cfg.group_include == ["ACME-*", "FOO-*"]
|
||||||
|
assert cfg.group_exclude == ["*-internal"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_paths_parsed() -> None:
|
||||||
|
cfg = load_config(
|
||||||
|
_minimal_env(LDAP_TLS_CERT="/etc/tls/tls.crt", LDAP_TLS_KEY="/etc/tls/tls.key")
|
||||||
|
)
|
||||||
|
assert cfg.tls_cert == "/etc/tls/tls.crt"
|
||||||
|
assert cfg.tls_key == "/etc/tls/tls.key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_is_frozen() -> None:
|
||||||
|
cfg = load_config(_minimal_env())
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
cfg.port = 1234 # type: ignore[misc]
|
||||||
254
ldap-gateway/tests/test_directory_search.py
Normal file
254
ldap-gateway/tests/test_directory_search.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""Tests for :mod:`ldap_gateway.directory` — tree build + search behaviour.
|
||||||
|
|
||||||
|
Builds a tree from a synthetic :class:`~ldap_gateway.cache.DirectorySnapshot` (no
|
||||||
|
network) and asserts the DIT shape, search results, group membership, exclusion of
|
||||||
|
filtered users, and the empty-then-swapped behaviour of :class:`DirectoryHolder`.
|
||||||
|
|
||||||
|
ldaptor's in-memory ``search``/``lookup``/``subtree`` fire their Deferreds and invoke
|
||||||
|
their callbacks synchronously for an in-memory tree, so we collect results via a
|
||||||
|
callback and read them straight away without pytest-twisted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ldap_gateway.cache import DirectorySnapshot
|
||||||
|
from ldap_gateway.directory import DirectoryHolder, build_tree
|
||||||
|
from ldap_gateway.identity import get_identity_resolver
|
||||||
|
|
||||||
|
from .conftest import make_config
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Synthetic KC fixtures + ldaptor sync helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _user(
|
||||||
|
user_id: str,
|
||||||
|
username: str,
|
||||||
|
*,
|
||||||
|
first: str = "",
|
||||||
|
last: str = "",
|
||||||
|
email: str = "",
|
||||||
|
enabled: bool = True,
|
||||||
|
attributes: dict[str, list[str]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"firstName": first,
|
||||||
|
"lastName": last,
|
||||||
|
"email": email,
|
||||||
|
"enabled": enabled,
|
||||||
|
"attributes": attributes or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _group(group_id: str, name: str) -> dict[str, Any]:
|
||||||
|
return {"id": group_id, "name": name, "attributes": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_snapshot() -> DirectorySnapshot:
|
||||||
|
alice = _user("u-alice", "alice", first="Alice", last="Anderson", email="a@x.com")
|
||||||
|
bob = _user("u-bob", "bob", first="Bob", last="Baker", email="b@x.com")
|
||||||
|
# Disabled user -> excluded by default.
|
||||||
|
carol = _user("u-carol", "carol", first="Carol", last="C", enabled=False)
|
||||||
|
# Service-account user -> excluded by default.
|
||||||
|
svc = _user("u-svc", "service-account-app", first="Svc", last="Acct")
|
||||||
|
|
||||||
|
devs = _group("g-devs", "ACME-bitbucket-read")
|
||||||
|
empties = _group("g-empty", "ACME-empty")
|
||||||
|
|
||||||
|
return DirectorySnapshot(
|
||||||
|
users=[alice, bob, carol, svc],
|
||||||
|
groups=[devs, empties],
|
||||||
|
members_by_group={
|
||||||
|
# devs contains alice + bob, plus the excluded carol (must be dropped).
|
||||||
|
"g-devs": [alice, bob, carol],
|
||||||
|
"g-empty": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _search(entry, **kwargs) -> list:
|
||||||
|
"""Run an ldaptor in-memory search synchronously, returning matched entries."""
|
||||||
|
found: list = []
|
||||||
|
entry.search(callback=found.append, **kwargs)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup(root, dn: str):
|
||||||
|
"""Look up a DN synchronously; return the entry or ``None`` if not found."""
|
||||||
|
box: list = []
|
||||||
|
d = root.lookup(dn)
|
||||||
|
d.addCallbacks(box.append, lambda _f: None)
|
||||||
|
return box[0] if box else None
|
||||||
|
|
||||||
|
|
||||||
|
def _values(entry, attr: str) -> set[str]:
|
||||||
|
"""Return the value set of an attribute on an entry (empty set if absent)."""
|
||||||
|
if attr not in entry:
|
||||||
|
return set()
|
||||||
|
return {str(v) for v in entry.get(attr)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_and_ou_structure() -> None:
|
||||||
|
config = make_config()
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
root = build_tree(_make_snapshot(), config, resolver)
|
||||||
|
|
||||||
|
assert root.dn.getText() == "dc=mgmt,dc=example,dc=com"
|
||||||
|
assert _values(root, "objectClass") >= {"dcObject", "organization"}
|
||||||
|
assert _values(root, "dc") == {"mgmt"}
|
||||||
|
assert _values(root, "o") == {"mgmt"}
|
||||||
|
|
||||||
|
base = config.base_dn
|
||||||
|
assert _lookup(root, f"ou=people,{base}") is not None
|
||||||
|
assert _lookup(root, f"ou=groups,{base}") is not None
|
||||||
|
assert _lookup(root, f"ou=services,{base}") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_entries_exist_at_expected_dns() -> None:
|
||||||
|
config = make_config()
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
root = build_tree(_make_snapshot(), config, resolver)
|
||||||
|
base = config.base_dn
|
||||||
|
|
||||||
|
alice = _lookup(root, f"uid=alice,ou=people,{base}")
|
||||||
|
assert alice is not None
|
||||||
|
assert _values(alice, "uid") == {"alice"}
|
||||||
|
assert _values(alice, "cn") == {"Alice Anderson"}
|
||||||
|
assert _values(alice, "sn") == {"Anderson"}
|
||||||
|
assert _values(alice, "mail") == {"a@x.com"}
|
||||||
|
assert _values(alice, "entryUUID") == {"u-alice"}
|
||||||
|
# POSIX attrs always present.
|
||||||
|
assert _values(alice, "uidNumber")
|
||||||
|
assert _values(alice, "homeDirectory") == {"/home/alice"}
|
||||||
|
# userPassword must never be served.
|
||||||
|
assert "userPassword" not in alice
|
||||||
|
|
||||||
|
assert _lookup(root, f"uid=bob,ou=people,{base}") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_by_uid_filter_returns_user() -> None:
|
||||||
|
config = make_config()
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
root = build_tree(_make_snapshot(), config, resolver)
|
||||||
|
|
||||||
|
results = _search(root, filterText="(uid=alice)")
|
||||||
|
dns = {e.dn.getText() for e in results}
|
||||||
|
assert dns == {"uid=alice,ou=people,dc=mgmt,dc=example,dc=com"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluded_users_absent() -> None:
|
||||||
|
config = make_config()
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
root = build_tree(_make_snapshot(), config, resolver)
|
||||||
|
base = config.base_dn
|
||||||
|
|
||||||
|
# Disabled user.
|
||||||
|
assert _lookup(root, f"uid=carol,ou=people,{base}") is None
|
||||||
|
assert _search(root, filterText="(uid=carol)") == []
|
||||||
|
# Service-account user.
|
||||||
|
assert _lookup(root, f"uid=service-account-app,ou=people,{base}") is None
|
||||||
|
assert _search(root, filterText="(uid=service-account-app)") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_membership_uses_member_and_memberUid() -> None:
|
||||||
|
config = make_config()
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
root = build_tree(_make_snapshot(), config, resolver)
|
||||||
|
base = config.base_dn
|
||||||
|
|
||||||
|
devs = _lookup(root, f"cn=ACME-bitbucket-read,ou=groups,{base}")
|
||||||
|
assert devs is not None
|
||||||
|
# Only the included members (alice, bob); excluded carol dropped.
|
||||||
|
assert _values(devs, "memberUid") == {"alice", "bob"}
|
||||||
|
assert _values(devs, "member") == {
|
||||||
|
f"uid=alice,ou=people,{base}",
|
||||||
|
f"uid=bob,ou=people,{base}",
|
||||||
|
}
|
||||||
|
# Non-empty group gains groupOfNames.
|
||||||
|
assert _values(devs, "objectClass") >= {"posixGroup", "groupOfNames"}
|
||||||
|
|
||||||
|
# alice's memberOf reflects the group.
|
||||||
|
alice = _lookup(root, f"uid=alice,ou=people,{base}")
|
||||||
|
assert f"cn=ACME-bitbucket-read,ou=groups,{base}" in _values(alice, "memberOf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_group_has_no_groupOfNames() -> None:
|
||||||
|
config = make_config()
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
root = build_tree(_make_snapshot(), config, resolver)
|
||||||
|
base = config.base_dn
|
||||||
|
|
||||||
|
empty = _lookup(root, f"cn=ACME-empty,ou=groups,{base}")
|
||||||
|
assert empty is not None
|
||||||
|
assert _values(empty, "objectClass") == {"top", "posixGroup"}
|
||||||
|
assert "member" not in empty
|
||||||
|
assert "memberUid" not in empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_account_entry_present_without_password() -> None:
|
||||||
|
svc_dn = "cn=svc-ro,ou=services,dc=mgmt,dc=example,dc=com"
|
||||||
|
config = make_config(service_accounts={svc_dn: "topsecret"})
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
root = build_tree(_make_snapshot(), config, resolver)
|
||||||
|
|
||||||
|
svc = _lookup(root, svc_dn)
|
||||||
|
assert svc is not None
|
||||||
|
assert _values(svc, "cn") == {"svc-ro"}
|
||||||
|
assert "userPassword" not in svc
|
||||||
|
# The configured password must not leak into any attribute.
|
||||||
|
for attr in svc.keys():
|
||||||
|
assert "topsecret" not in _values(svc, attr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_holder_starts_empty_then_reflects_swap() -> None:
|
||||||
|
config = make_config()
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
holder = DirectoryHolder(config, resolver)
|
||||||
|
base = config.base_dn
|
||||||
|
|
||||||
|
# Empty-but-valid before any refresh: root + OUs exist, no people.
|
||||||
|
root = holder.tree
|
||||||
|
assert root.dn.getText() == base
|
||||||
|
assert _lookup(root, f"ou=people,{base}") is not None
|
||||||
|
assert _search(root, filterText="(uid=alice)") == []
|
||||||
|
|
||||||
|
# After a swap the new tree is reflected.
|
||||||
|
holder.swap(_make_snapshot())
|
||||||
|
swapped = holder.tree
|
||||||
|
assert swapped is not root
|
||||||
|
assert _lookup(swapped, f"uid=alice,ou=people,{base}") is not None
|
||||||
|
assert {e.dn.getText() for e in _search(swapped, filterText="(uid=bob)")} == {
|
||||||
|
f"uid=bob,ou=people,{base}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_user_when_resolver_returns_none() -> None:
|
||||||
|
# cert_cn resolver returns None for users lacking the cert attribute.
|
||||||
|
config = make_config(identity_source="cert_cn", cert_cn_attribute="certCN")
|
||||||
|
resolver = get_identity_resolver(config)
|
||||||
|
base = config.base_dn
|
||||||
|
|
||||||
|
with_cn = _user("u-dave", "dave", first="Dave", attributes={"certCN": ["dave.cn"]})
|
||||||
|
without_cn = _user("u-erin", "erin", first="Erin")
|
||||||
|
snapshot = DirectorySnapshot(
|
||||||
|
users=[with_cn, without_cn],
|
||||||
|
groups=[],
|
||||||
|
members_by_group={},
|
||||||
|
)
|
||||||
|
root = build_tree(snapshot, config, resolver)
|
||||||
|
|
||||||
|
# cert_cn resolver uses cn= as the RDN attribute.
|
||||||
|
assert _lookup(root, f"cn=dave.cn,ou=people,{base}") is not None
|
||||||
|
# erin had no certCN attribute -> skipped entirely.
|
||||||
|
assert _search(root, filterText="(uid=erin)") == []
|
||||||
195
ldap-gateway/tests/test_handlers.py
Normal file
195
ldap-gateway/tests/test_handlers.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tests for ldap_gateway.handlers — bind policy and write rejection.
|
||||||
|
|
||||||
|
No network: we construct a :class:`~ldap_gateway.handlers.GatewayLDAPServer` with a
|
||||||
|
fake factory exposing ``.config`` and ``.holder`` (a minimal in-memory ldaptor root) and
|
||||||
|
invoke the ``handle_*`` methods with real ldaptor request PDUs, asserting the response
|
||||||
|
result code or the raised LDAP error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from ldaptor import interfaces
|
||||||
|
from ldaptor.inmemory import ReadOnlyInMemoryLDAPEntry
|
||||||
|
from ldaptor.protocols import pureldap
|
||||||
|
from ldaptor.protocols.ldap import ldaperrors
|
||||||
|
|
||||||
|
from ldap_gateway.handlers import GatewayLDAPServer, LDAPServerFactory
|
||||||
|
|
||||||
|
from .conftest import make_config
|
||||||
|
|
||||||
|
SVC_DN = "cn=svc,ou=services,dc=mgmt,dc=example,dc=com"
|
||||||
|
SVC_PW = "s3cret"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHolder:
|
||||||
|
"""Minimal DirectoryHolder stand-in: exposes a live ``.tree`` property."""
|
||||||
|
|
||||||
|
def __init__(self, tree: ReadOnlyInMemoryLDAPEntry) -> None:
|
||||||
|
self._tree = tree
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tree(self) -> ReadOnlyInMemoryLDAPEntry:
|
||||||
|
return self._tree
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFactory:
|
||||||
|
"""Fake factory exposing ``.config`` and ``.holder`` like LDAPServerFactory."""
|
||||||
|
|
||||||
|
def __init__(self, config: Any, holder: _FakeHolder) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.holder = holder
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tree() -> ReadOnlyInMemoryLDAPEntry:
|
||||||
|
root = ReadOnlyInMemoryLDAPEntry(
|
||||||
|
"dc=mgmt,dc=example,dc=com", {"objectClass": ["top", "dcObject"]}
|
||||||
|
)
|
||||||
|
root.addChild("ou=people", {"objectClass": ["organizationalUnit"], "ou": ["people"]})
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _make_server(config: Any) -> GatewayLDAPServer:
|
||||||
|
tree = _make_tree()
|
||||||
|
server = GatewayLDAPServer()
|
||||||
|
server.factory = _FakeFactory(config, _FakeHolder(tree)) # type: ignore[assignment]
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def _noop_reply(_response: Any) -> None: # pragma: no cover - unused by these handlers
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _result_code(handler_return: Any) -> int:
|
||||||
|
"""Extract the result code from a handler return value or a fired Deferred."""
|
||||||
|
result: Any = handler_return
|
||||||
|
captured: list[Any] = []
|
||||||
|
if hasattr(handler_return, "addCallback"):
|
||||||
|
handler_return.addCallback(captured.append)
|
||||||
|
assert captured, "Deferred did not fire synchronously"
|
||||||
|
result = captured[0]
|
||||||
|
return result.resultCode
|
||||||
|
|
||||||
|
|
||||||
|
# --- adapter wiring ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_adapts_to_live_tree() -> None:
|
||||||
|
"""The registered adapter resolves IConnectedLDAPEntry to holder.tree (live)."""
|
||||||
|
config = make_config()
|
||||||
|
tree = _make_tree()
|
||||||
|
holder = _FakeHolder(tree)
|
||||||
|
# Use a real LDAPServerFactory so the registered adapter applies.
|
||||||
|
factory = LDAPServerFactory(holder, config) # type: ignore[arg-type]
|
||||||
|
resolved = interfaces.IConnectedLDAPEntry(factory)
|
||||||
|
assert resolved is tree
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_protocol_wires_factory() -> None:
|
||||||
|
config = make_config()
|
||||||
|
factory = LDAPServerFactory(_FakeHolder(_make_tree()), config) # type: ignore[arg-type]
|
||||||
|
proto = factory.buildProtocol(("127.0.0.1", 0))
|
||||||
|
assert isinstance(proto, GatewayLDAPServer)
|
||||||
|
assert proto.factory is factory
|
||||||
|
|
||||||
|
|
||||||
|
# --- bind policy -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_bind_allowed_when_enabled() -> None:
|
||||||
|
server = _make_server(make_config(allow_anon=True))
|
||||||
|
request = pureldap.LDAPBindRequest(version=3, dn=b"", auth=b"")
|
||||||
|
code = _result_code(server.handle_LDAPBindRequest(request, None, _noop_reply))
|
||||||
|
assert code == ldaperrors.Success.resultCode
|
||||||
|
assert server.boundUser is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_bind_denied_by_default() -> None:
|
||||||
|
server = _make_server(make_config(allow_anon=False))
|
||||||
|
request = pureldap.LDAPBindRequest(version=3, dn=b"", auth=b"")
|
||||||
|
with pytest.raises(ldaperrors.LDAPInvalidCredentials):
|
||||||
|
server.handle_LDAPBindRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_bind_success() -> None:
|
||||||
|
server = _make_server(make_config(service_accounts={SVC_DN: SVC_PW}))
|
||||||
|
request = pureldap.LDAPBindRequest(version=3, dn=SVC_DN.encode(), auth=SVC_PW.encode())
|
||||||
|
ret = server.handle_LDAPBindRequest(request, None, _noop_reply)
|
||||||
|
assert _result_code(ret) == ldaperrors.Success.resultCode
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_bind_wrong_password_denied() -> None:
|
||||||
|
server = _make_server(make_config(service_accounts={SVC_DN: SVC_PW}))
|
||||||
|
request = pureldap.LDAPBindRequest(version=3, dn=SVC_DN.encode(), auth=b"wrong")
|
||||||
|
with pytest.raises(ldaperrors.LDAPInvalidCredentials):
|
||||||
|
server.handle_LDAPBindRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_user_bind_denied() -> None:
|
||||||
|
server = _make_server(make_config(service_accounts={SVC_DN: SVC_PW}))
|
||||||
|
request = pureldap.LDAPBindRequest(
|
||||||
|
version=3,
|
||||||
|
dn=b"uid=alice,ou=people,dc=mgmt,dc=example,dc=com",
|
||||||
|
auth=b"hunter2",
|
||||||
|
)
|
||||||
|
with pytest.raises(ldaperrors.LDAPInvalidCredentials):
|
||||||
|
server.handle_LDAPBindRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_dn_with_empty_password_denied() -> None:
|
||||||
|
server = _make_server(make_config(service_accounts={SVC_DN: SVC_PW}))
|
||||||
|
request = pureldap.LDAPBindRequest(version=3, dn=SVC_DN.encode(), auth=b"")
|
||||||
|
with pytest.raises(ldaperrors.LDAPInvalidCredentials):
|
||||||
|
server.handle_LDAPBindRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_version_rejected() -> None:
|
||||||
|
server = _make_server(make_config(allow_anon=True))
|
||||||
|
request = pureldap.LDAPBindRequest(version=2, dn=b"", auth=b"")
|
||||||
|
with pytest.raises(ldaperrors.LDAPProtocolError):
|
||||||
|
server.handle_LDAPBindRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
# --- write rejection ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_request_unwilling_to_perform() -> None:
|
||||||
|
server = _make_server(make_config())
|
||||||
|
request = pureldap.LDAPAddRequest(
|
||||||
|
entry=b"uid=bob,ou=people,dc=mgmt,dc=example,dc=com", attributes=[]
|
||||||
|
)
|
||||||
|
with pytest.raises(ldaperrors.LDAPUnwillingToPerform):
|
||||||
|
server.handle_LDAPAddRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def test_modify_request_unwilling_to_perform() -> None:
|
||||||
|
server = _make_server(make_config())
|
||||||
|
request = pureldap.LDAPModifyRequest(
|
||||||
|
object=b"uid=bob,ou=people,dc=mgmt,dc=example,dc=com", modification=[]
|
||||||
|
)
|
||||||
|
with pytest.raises(ldaperrors.LDAPUnwillingToPerform):
|
||||||
|
server.handle_LDAPModifyRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def test_del_request_unwilling_to_perform() -> None:
|
||||||
|
server = _make_server(make_config())
|
||||||
|
request = pureldap.LDAPDelRequest(value=b"uid=bob,ou=people,dc=mgmt,dc=example,dc=com")
|
||||||
|
with pytest.raises(ldaperrors.LDAPUnwillingToPerform):
|
||||||
|
server.handle_LDAPDelRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def test_modifydn_request_unwilling_to_perform() -> None:
|
||||||
|
server = _make_server(make_config())
|
||||||
|
request = pureldap.LDAPModifyDNRequest(
|
||||||
|
entry=b"uid=bob,ou=people,dc=mgmt,dc=example,dc=com",
|
||||||
|
newrdn=b"uid=bobby",
|
||||||
|
deleteoldrdn=True,
|
||||||
|
)
|
||||||
|
with pytest.raises(ldaperrors.LDAPUnwillingToPerform):
|
||||||
|
server.handle_LDAPModifyDNRequest(request, None, _noop_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwilling_to_perform_result_code_is_53() -> None:
|
||||||
|
assert ldaperrors.LDAPUnwillingToPerform.resultCode == 53
|
||||||
99
ldap-gateway/tests/test_identity.py
Normal file
99
ldap-gateway/tests/test_identity.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests for ldap_gateway.identity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ldap_gateway.identity import (
|
||||||
|
CertCNIdentityResolver,
|
||||||
|
IdentityResolver,
|
||||||
|
UsernameIdentityResolver,
|
||||||
|
get_identity_resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .conftest import make_config
|
||||||
|
|
||||||
|
|
||||||
|
# --- UsernameIdentityResolver ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_username_resolver_rdn_attr() -> None:
|
||||||
|
assert UsernameIdentityResolver().rdn_attr == "uid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_username_resolver_happy_path() -> None:
|
||||||
|
resolver = UsernameIdentityResolver()
|
||||||
|
assert resolver.resolve({"id": "u1", "username": "alice"}) == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_username_resolver_missing_username_returns_none() -> None:
|
||||||
|
resolver = UsernameIdentityResolver()
|
||||||
|
assert resolver.resolve({"id": "u1"}) is None
|
||||||
|
assert resolver.resolve({"id": "u1", "username": ""}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_username_resolver_satisfies_protocol() -> None:
|
||||||
|
assert isinstance(UsernameIdentityResolver(), IdentityResolver)
|
||||||
|
|
||||||
|
|
||||||
|
# --- CertCNIdentityResolver (stub) -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_cn_resolver_rdn_attr() -> None:
|
||||||
|
assert CertCNIdentityResolver("certCN").rdn_attr == "cn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_cn_resolver_returns_value_when_attr_present() -> None:
|
||||||
|
resolver = CertCNIdentityResolver("certCN")
|
||||||
|
kc_user = {"id": "u1", "attributes": {"certCN": ["alice.cert"]}}
|
||||||
|
assert resolver.resolve(kc_user) == "alice.cert"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_cn_resolver_returns_none_when_attr_absent() -> None:
|
||||||
|
resolver = CertCNIdentityResolver("certCN")
|
||||||
|
assert resolver.resolve({"id": "u1", "attributes": {}}) is None
|
||||||
|
assert resolver.resolve({"id": "u1"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_cn_resolver_returns_none_when_attr_empty() -> None:
|
||||||
|
resolver = CertCNIdentityResolver("certCN")
|
||||||
|
assert resolver.resolve({"id": "u1", "attributes": {"certCN": []}}) is None
|
||||||
|
assert resolver.resolve({"id": "u1", "attributes": {"certCN": [""]}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_cn_resolver_never_crashes_on_odd_input() -> None:
|
||||||
|
resolver = CertCNIdentityResolver("certCN")
|
||||||
|
# attributes is None / not a dict-ish shape: must not raise, returns None.
|
||||||
|
assert resolver.resolve({"id": "u1", "attributes": None}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_cn_resolver_honors_configured_attribute_name() -> None:
|
||||||
|
resolver = CertCNIdentityResolver("myCustomCN")
|
||||||
|
kc_user = {"id": "u1", "attributes": {"myCustomCN": ["bob"], "certCN": ["ignored"]}}
|
||||||
|
assert resolver.resolve(kc_user) == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_cn_resolver_satisfies_protocol() -> None:
|
||||||
|
assert isinstance(CertCNIdentityResolver("certCN"), IdentityResolver)
|
||||||
|
|
||||||
|
|
||||||
|
# --- get_identity_resolver factory -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_username() -> None:
|
||||||
|
resolver = get_identity_resolver(make_config(identity_source="username"))
|
||||||
|
assert isinstance(resolver, UsernameIdentityResolver)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_cert_cn_uses_configured_attribute() -> None:
|
||||||
|
resolver = get_identity_resolver(
|
||||||
|
make_config(identity_source="cert_cn", cert_cn_attribute="customCN")
|
||||||
|
)
|
||||||
|
assert isinstance(resolver, CertCNIdentityResolver)
|
||||||
|
kc_user = {"id": "u1", "attributes": {"customCN": ["x"]}}
|
||||||
|
assert resolver.resolve(kc_user) == "x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_unknown_source_raises() -> None:
|
||||||
|
with pytest.raises(ValueError, match="identity_source"):
|
||||||
|
get_identity_resolver(make_config(identity_source="bogus"))
|
||||||
390
ldap-gateway/tests/test_kc_client.py
Normal file
390
ldap-gateway/tests/test_kc_client.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
"""Tests for ldap_gateway.kc_client.KeycloakClient.
|
||||||
|
|
||||||
|
All HTTP is mocked — no real network. We replace the client's
|
||||||
|
``requests.Session`` with a fake that records calls and returns canned
|
||||||
|
responses, which lets us assert token caching, pagination stitching, subgroup
|
||||||
|
flattening, and KeycloakError on failures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ldap_gateway.kc_client import KeycloakClient, KeycloakError
|
||||||
|
|
||||||
|
from .conftest import make_config as _make_config
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config construction ──────────────────────────────────────────────
|
||||||
|
# Thin wrapper over the shared conftest helper so existing call sites keep working.
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(**overrides: Any):
|
||||||
|
if _make_config is not None:
|
||||||
|
return _make_config(**overrides)
|
||||||
|
|
||||||
|
from ldap_gateway.config import Config
|
||||||
|
|
||||||
|
defaults: dict[str, Any] = dict(
|
||||||
|
keycloak_url="https://kc.example.com",
|
||||||
|
keycloak_realm="mgmt",
|
||||||
|
keycloak_client_id="ldap-gateway",
|
||||||
|
keycloak_client_secret="s3cret",
|
||||||
|
keycloak_verify_ssl=True,
|
||||||
|
keycloak_timeout=30,
|
||||||
|
base_dn="dc=mgmt,dc=example,dc=com",
|
||||||
|
bind_host="0.0.0.0",
|
||||||
|
port=389,
|
||||||
|
ldaps_port=636,
|
||||||
|
tls_cert=None,
|
||||||
|
tls_key=None,
|
||||||
|
allow_anon=False,
|
||||||
|
service_accounts={},
|
||||||
|
identity_source="username",
|
||||||
|
cert_cn_attribute="certCN",
|
||||||
|
uid_min=100000,
|
||||||
|
uid_max=599999,
|
||||||
|
gid_min=100000,
|
||||||
|
gid_max=599999,
|
||||||
|
default_shell="/bin/bash",
|
||||||
|
home_base="/home",
|
||||||
|
cache_ttl=60,
|
||||||
|
refresh_interval=60,
|
||||||
|
exclude_disabled=True,
|
||||||
|
exclude_service_accounts=True,
|
||||||
|
group_include=[],
|
||||||
|
group_exclude=[],
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return Config(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fake HTTP plumbing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, status_code: int = 200, json_data: Any = None, text: str = ""):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._json_data = json_data
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
if isinstance(self._json_data, Exception):
|
||||||
|
raise self._json_data
|
||||||
|
return self._json_data
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
"""Records GET/POST calls and replays queued responses (or a router)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.verify: bool = True
|
||||||
|
self.get_calls: list[dict] = []
|
||||||
|
self.post_calls: list[dict] = []
|
||||||
|
# GET routing: callable(url, params) -> FakeResponse, set by tests.
|
||||||
|
self.get_router = None
|
||||||
|
# POST routing for the token endpoint.
|
||||||
|
self.post_response: FakeResponse | None = None
|
||||||
|
self.post_side_effect: Exception | None = None
|
||||||
|
|
||||||
|
def post(self, url, data=None, timeout=None, verify=None, **kwargs):
|
||||||
|
self.post_calls.append({"url": url, "data": data, "timeout": timeout, "verify": verify})
|
||||||
|
if self.post_side_effect is not None:
|
||||||
|
raise self.post_side_effect
|
||||||
|
return self.post_response
|
||||||
|
|
||||||
|
def get(self, url, headers=None, params=None, timeout=None, verify=None, **kwargs):
|
||||||
|
self.get_calls.append({"url": url, "headers": headers, "params": params})
|
||||||
|
assert self.get_router is not None, "get_router not configured for this test"
|
||||||
|
return self.get_router(url, params or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _token_response(expires_in: int = 300, token: str = "tok-1") -> FakeResponse:
|
||||||
|
return FakeResponse(200, {"access_token": token, "expires_in": expires_in})
|
||||||
|
|
||||||
|
|
||||||
|
def make_client(session: FakeSession, **config_overrides: Any) -> KeycloakClient:
|
||||||
|
client = KeycloakClient(make_config(**config_overrides))
|
||||||
|
client._session = session
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
# ── Token fetch + caching ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_fetched_once_and_cached():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response(expires_in=300, token="tok-A")
|
||||||
|
# All GETs return an empty list (short page -> stop).
|
||||||
|
session.get_router = lambda url, params: FakeResponse(200, [])
|
||||||
|
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
client.get_all_users()
|
||||||
|
client.get_all_users()
|
||||||
|
|
||||||
|
# Token requested exactly once despite two API calls.
|
||||||
|
assert len(session.post_calls) == 1
|
||||||
|
token_call = session.post_calls[0]
|
||||||
|
assert token_call["url"].endswith("/realms/mgmt/protocol/openid-connect/token")
|
||||||
|
assert token_call["data"]["grant_type"] == "client_credentials"
|
||||||
|
assert token_call["data"]["client_id"] == "ldap-gateway"
|
||||||
|
assert token_call["data"]["client_secret"] == "s3cret"
|
||||||
|
|
||||||
|
# Bearer token threaded into the GET headers.
|
||||||
|
assert session.get_calls[0]["headers"]["Authorization"] == "Bearer tok-A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_refreshed_when_near_expiry():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response(expires_in=0, token="tok-A")
|
||||||
|
session.get_router = lambda url, params: FakeResponse(200, [])
|
||||||
|
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
# expires_in=0 means it's always within the refresh buffer -> refetch.
|
||||||
|
client.get_all_users()
|
||||||
|
client.get_all_users()
|
||||||
|
|
||||||
|
assert len(session.post_calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_failure_raises_keycloak_error():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = FakeResponse(401, text="invalid_client")
|
||||||
|
session.get_router = lambda url, params: FakeResponse(200, [])
|
||||||
|
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
with pytest.raises(KeycloakError):
|
||||||
|
client.get_all_users()
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_transport_error_raises_keycloak_error():
|
||||||
|
import requests
|
||||||
|
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_side_effect = requests.ConnectionError("boom")
|
||||||
|
session.get_router = lambda url, params: FakeResponse(200, [])
|
||||||
|
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
with pytest.raises(KeycloakError):
|
||||||
|
client.get_all_users()
|
||||||
|
|
||||||
|
|
||||||
|
# ── get_all_users pagination ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_users_paginates_and_stitches():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
|
||||||
|
page_full = [{"id": f"u{i}", "username": f"user{i}", "attributes": {}} for i in range(100)]
|
||||||
|
page_short = [{"id": "u100", "username": "user100", "attributes": {}}]
|
||||||
|
|
||||||
|
def router(url, params):
|
||||||
|
assert url.endswith("/admin/realms/mgmt/users")
|
||||||
|
# Full representation must be requested so attributes are included.
|
||||||
|
assert params.get("briefRepresentation") == "false"
|
||||||
|
first = params["first"]
|
||||||
|
if first == 0:
|
||||||
|
return FakeResponse(200, page_full)
|
||||||
|
if first == 100:
|
||||||
|
return FakeResponse(200, page_short)
|
||||||
|
return FakeResponse(200, [])
|
||||||
|
|
||||||
|
session.get_router = router
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
users = client.get_all_users()
|
||||||
|
|
||||||
|
assert len(users) == 101
|
||||||
|
assert users[0]["id"] == "u0"
|
||||||
|
assert users[-1]["id"] == "u100"
|
||||||
|
# Two list pages fetched (100 -> continue, 1 -> stop).
|
||||||
|
assert len(session.get_calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_users_single_short_page_stops():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
session.get_router = lambda url, params: FakeResponse(200, [{"id": "u0", "attributes": {}}])
|
||||||
|
|
||||||
|
client = make_client(session)
|
||||||
|
users = client.get_all_users()
|
||||||
|
|
||||||
|
assert len(users) == 1
|
||||||
|
assert len(session.get_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_users_http_error_raises():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
session.get_router = lambda url, params: FakeResponse(500, text="kaboom")
|
||||||
|
|
||||||
|
client = make_client(session)
|
||||||
|
with pytest.raises(KeycloakError):
|
||||||
|
client.get_all_users()
|
||||||
|
|
||||||
|
|
||||||
|
# ── get_all_groups flattening ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_groups_flattens_inline_subgroups():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
|
||||||
|
tree = [
|
||||||
|
{
|
||||||
|
"id": "g-acme",
|
||||||
|
"name": "ACME",
|
||||||
|
"attributes": {"foo": ["bar"]},
|
||||||
|
"subGroups": [
|
||||||
|
{
|
||||||
|
"id": "g-acme-bb-read",
|
||||||
|
"name": "ACME-bitbucket-read",
|
||||||
|
"attributes": {},
|
||||||
|
"subGroups": [
|
||||||
|
{
|
||||||
|
"id": "g-deep",
|
||||||
|
"name": "ACME-bitbucket-read-deep",
|
||||||
|
"attributes": {},
|
||||||
|
"subGroups": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"id": "g-vela", "name": "VELA", "attributes": {}, "subGroups": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
def router(url, params):
|
||||||
|
if url.endswith("/admin/realms/mgmt/groups"):
|
||||||
|
return FakeResponse(200, tree)
|
||||||
|
raise AssertionError(f"unexpected GET {url}")
|
||||||
|
|
||||||
|
session.get_router = router
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
groups = client.get_all_groups()
|
||||||
|
|
||||||
|
names = {g["name"] for g in groups}
|
||||||
|
assert names == {
|
||||||
|
"ACME",
|
||||||
|
"ACME-bitbucket-read",
|
||||||
|
"ACME-bitbucket-read-deep",
|
||||||
|
"VELA",
|
||||||
|
}
|
||||||
|
# Every returned dict has the required keys.
|
||||||
|
for g in groups:
|
||||||
|
assert set(["id", "name", "attributes"]).issubset(g.keys())
|
||||||
|
# Attributes preserved from the tree.
|
||||||
|
acme = next(g for g in groups if g["name"] == "ACME")
|
||||||
|
assert acme["attributes"] == {"foo": ["bar"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_groups_fetches_full_rep_when_attributes_missing():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
|
||||||
|
# List view omits "attributes" -> client must GET /groups/{id} and
|
||||||
|
# /groups/{id}/children.
|
||||||
|
tree = [{"id": "g-acme", "name": "ACME"}]
|
||||||
|
full_acme = {
|
||||||
|
"id": "g-acme",
|
||||||
|
"name": "ACME",
|
||||||
|
"attributes": {"k": ["v"]},
|
||||||
|
}
|
||||||
|
children = [{"id": "g-child", "name": "ACME-child", "attributes": {}}]
|
||||||
|
|
||||||
|
def router(url, params):
|
||||||
|
if url.endswith("/admin/realms/mgmt/groups"):
|
||||||
|
return FakeResponse(200, tree)
|
||||||
|
if url.endswith("/admin/realms/mgmt/groups/g-acme"):
|
||||||
|
return FakeResponse(200, full_acme)
|
||||||
|
if url.endswith("/admin/realms/mgmt/groups/g-acme/children"):
|
||||||
|
return FakeResponse(200, children)
|
||||||
|
if url.endswith("/admin/realms/mgmt/groups/g-child/children"):
|
||||||
|
return FakeResponse(200, [])
|
||||||
|
raise AssertionError(f"unexpected GET {url}")
|
||||||
|
|
||||||
|
session.get_router = router
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
groups = client.get_all_groups()
|
||||||
|
|
||||||
|
names = {g["name"] for g in groups}
|
||||||
|
assert names == {"ACME", "ACME-child"}
|
||||||
|
acme = next(g for g in groups if g["name"] == "ACME")
|
||||||
|
assert acme["attributes"] == {"k": ["v"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_groups_http_error_raises():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
session.get_router = lambda url, params: FakeResponse(503, text="down")
|
||||||
|
|
||||||
|
client = make_client(session)
|
||||||
|
with pytest.raises(KeycloakError):
|
||||||
|
client.get_all_groups()
|
||||||
|
|
||||||
|
|
||||||
|
# ── get_group_members pagination ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_group_members_paginates():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
|
||||||
|
page_full = [{"id": f"m{i}", "username": f"member{i}"} for i in range(100)]
|
||||||
|
page_short = [{"id": "m100", "username": "member100"}]
|
||||||
|
|
||||||
|
def router(url, params):
|
||||||
|
assert url.endswith("/admin/realms/mgmt/groups/g1/members")
|
||||||
|
first = params["first"]
|
||||||
|
if first == 0:
|
||||||
|
return FakeResponse(200, page_full)
|
||||||
|
if first == 100:
|
||||||
|
return FakeResponse(200, page_short)
|
||||||
|
return FakeResponse(200, [])
|
||||||
|
|
||||||
|
session.get_router = router
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
members = client.get_group_members("g1")
|
||||||
|
|
||||||
|
assert len(members) == 101
|
||||||
|
assert members[-1]["id"] == "m100"
|
||||||
|
assert len(session.get_calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_group_members_http_error_raises():
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
session.get_router = lambda url, params: FakeResponse(404, text="no such group")
|
||||||
|
|
||||||
|
client = make_client(session)
|
||||||
|
with pytest.raises(KeycloakError):
|
||||||
|
client.get_group_members("missing")
|
||||||
|
|
||||||
|
|
||||||
|
# ── transport-level error on admin call ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_transport_error_raises_keycloak_error():
|
||||||
|
import requests
|
||||||
|
|
||||||
|
session = FakeSession()
|
||||||
|
session.post_response = _token_response()
|
||||||
|
|
||||||
|
def router(url, params):
|
||||||
|
raise requests.Timeout("slow")
|
||||||
|
|
||||||
|
session.get_router = router
|
||||||
|
client = make_client(session)
|
||||||
|
|
||||||
|
with pytest.raises(KeycloakError):
|
||||||
|
client.get_all_users()
|
||||||
390
ldap-gateway/tests/test_mapping.py
Normal file
390
ldap-gateway/tests/test_mapping.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
"""Unit tests for ldap_gateway.mapping (pure functions)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ldap_gateway import mapping
|
||||||
|
|
||||||
|
# CONTRACT-NOTE: Per the task, Config is built via the shared tests/conftest.py
|
||||||
|
# `make_config` helper (owned by the foundation agent). If conftest is not yet in
|
||||||
|
# place these tests will fail to collect; the helper is the agreed seam.
|
||||||
|
from .conftest import make_config
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fake identity resolvers (the real ones live in ldap_gateway.identity, owned by
|
||||||
|
# another agent). These satisfy the IdentityResolver Protocol used by mapping.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUsernameResolver:
|
||||||
|
"""Mimics UsernameIdentityResolver: rdn_attr='uid', value=username."""
|
||||||
|
|
||||||
|
rdn_attr = "uid"
|
||||||
|
|
||||||
|
def resolve(self, kc_user: dict) -> str | None:
|
||||||
|
return kc_user.get("username") or None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCNResolver:
|
||||||
|
"""A resolver whose RDN attribute is 'cn' (e.g. a cert-CN style resolver)."""
|
||||||
|
|
||||||
|
rdn_attr = "cn"
|
||||||
|
|
||||||
|
def __init__(self, attr: str = "certCN") -> None:
|
||||||
|
self._attr = attr
|
||||||
|
|
||||||
|
def resolve(self, kc_user: dict) -> str | None:
|
||||||
|
values = (kc_user.get("attributes") or {}).get(self._attr)
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_user(**overrides) -> dict:
|
||||||
|
user = {
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"username": "jdoe",
|
||||||
|
"firstName": "Jane",
|
||||||
|
"lastName": "Doe",
|
||||||
|
"email": "jane.doe@example.com",
|
||||||
|
"enabled": True,
|
||||||
|
"attributes": {},
|
||||||
|
}
|
||||||
|
user.update(overrides)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def make_group(**overrides) -> dict:
|
||||||
|
group = {
|
||||||
|
"id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"name": "ACME-bitbucket-read",
|
||||||
|
"attributes": {},
|
||||||
|
}
|
||||||
|
group.update(overrides)
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DN escaping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_plain_value_unchanged():
|
||||||
|
assert mapping.escape_dn_value("jdoe") == "jdoe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_empty_value():
|
||||||
|
assert mapping.escape_dn_value("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw, expected",
|
||||||
|
[
|
||||||
|
("a,b", "a\\,b"),
|
||||||
|
("a+b", "a\\+b"),
|
||||||
|
('a"b', 'a\\"b'),
|
||||||
|
("a\\b", "a\\\\b"),
|
||||||
|
("a<b", "a\\<b"),
|
||||||
|
("a>b", "a\\>b"),
|
||||||
|
("a;b", "a\\;b"),
|
||||||
|
("a=b", "a\\=b"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_escape_special_characters(raw, expected):
|
||||||
|
assert mapping.escape_dn_value(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_leading_hash():
|
||||||
|
assert mapping.escape_dn_value("#abc") == "\\#abc"
|
||||||
|
# A non-leading '#' is not escaped.
|
||||||
|
assert mapping.escape_dn_value("a#b") == "a#b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_leading_and_trailing_space():
|
||||||
|
assert mapping.escape_dn_value(" abc") == "\\ abc"
|
||||||
|
assert mapping.escape_dn_value("abc ") == "abc\\ "
|
||||||
|
assert mapping.escape_dn_value(" abc ") == "\\ abc\\ "
|
||||||
|
# Interior space is untouched.
|
||||||
|
assert mapping.escape_dn_value("a b") == "a b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_null_byte():
|
||||||
|
assert mapping.escape_dn_value("a\x00b") == "a\\00b"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DN builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_dn_uses_resolver_rdn_attr():
|
||||||
|
config = make_config(base_dn="dc=mgmt,dc=example,dc=com")
|
||||||
|
resolver = FakeUsernameResolver()
|
||||||
|
assert (
|
||||||
|
mapping.user_dn("jdoe", config, resolver) == "uid=jdoe,ou=people,dc=mgmt,dc=example,dc=com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_dn_cn_resolver():
|
||||||
|
config = make_config(base_dn="dc=mgmt,dc=example,dc=com")
|
||||||
|
resolver = FakeCNResolver()
|
||||||
|
assert (
|
||||||
|
mapping.user_dn("Jane Doe", config, resolver)
|
||||||
|
== "cn=Jane Doe,ou=people,dc=mgmt,dc=example,dc=com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_dn_escapes_value():
|
||||||
|
config = make_config(base_dn="dc=mgmt,dc=example,dc=com")
|
||||||
|
resolver = FakeUsernameResolver()
|
||||||
|
assert (
|
||||||
|
mapping.user_dn("a,b", config, resolver) == "uid=a\\,b,ou=people,dc=mgmt,dc=example,dc=com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_dn_escapes_value():
|
||||||
|
config = make_config(base_dn="dc=mgmt,dc=example,dc=com")
|
||||||
|
assert (
|
||||||
|
mapping.group_dn("ACME+team", config)
|
||||||
|
== "cn=ACME\\+team,ou=groups,dc=mgmt,dc=example,dc=com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ou_dns():
|
||||||
|
config = make_config(base_dn="dc=mgmt,dc=example,dc=com")
|
||||||
|
assert mapping.people_ou_dn(config) == "ou=people,dc=mgmt,dc=example,dc=com"
|
||||||
|
assert mapping.groups_ou_dn(config) == "ou=groups,dc=mgmt,dc=example,dc=com"
|
||||||
|
assert mapping.services_ou_dn(config) == "ou=services,dc=mgmt,dc=example,dc=com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UID / GID
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_number_deterministic_same_id():
|
||||||
|
config = make_config(uid_min=100000, uid_max=599999)
|
||||||
|
u1 = make_user(id="abc", username="one")
|
||||||
|
u2 = make_user(id="abc", username="renamed") # same id, different name
|
||||||
|
assert mapping.uid_number(u1, config) == mapping.uid_number(u2, config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_number_within_range():
|
||||||
|
config = make_config(uid_min=100000, uid_max=599999)
|
||||||
|
for i in range(200):
|
||||||
|
user = make_user(id=f"id-{i}")
|
||||||
|
n = mapping.uid_number(user, config)
|
||||||
|
assert 100000 <= n < 599999
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_number_changes_with_id():
|
||||||
|
config = make_config(uid_min=100000, uid_max=599999)
|
||||||
|
a = mapping.uid_number(make_user(id="aaaa"), config)
|
||||||
|
b = mapping.uid_number(make_user(id="bbbb"), config)
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_number_kc_attribute_override():
|
||||||
|
config = make_config(uid_min=100000, uid_max=599999)
|
||||||
|
user = make_user(attributes={"uidNumber": ["4242"]})
|
||||||
|
assert mapping.uid_number(user, config) == 4242
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_number_bad_attribute_falls_back_to_derived():
|
||||||
|
config = make_config(uid_min=100000, uid_max=599999)
|
||||||
|
user = make_user(attributes={"uidNumber": ["not-an-int"]})
|
||||||
|
derived = mapping.uid_number(make_user(attributes={}), config)
|
||||||
|
assert mapping.uid_number(user, config) == derived
|
||||||
|
|
||||||
|
|
||||||
|
def test_gid_number_override_and_derivation():
|
||||||
|
config = make_config(gid_min=100000, gid_max=599999)
|
||||||
|
group = make_group(attributes={"gidNumber": ["5005"]})
|
||||||
|
assert mapping.gid_number(group, config) == 5005
|
||||||
|
|
||||||
|
derived_group = make_group(id="grp-x", attributes={})
|
||||||
|
n = mapping.gid_number(derived_group, config)
|
||||||
|
assert 100000 <= n < 599999
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inclusion / exclusion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclude_disabled_user():
|
||||||
|
config = make_config(exclude_disabled=True)
|
||||||
|
assert mapping.should_include_user(make_user(enabled=True), config) is True
|
||||||
|
assert mapping.should_include_user(make_user(enabled=False), config) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_keep_disabled_when_not_excluding():
|
||||||
|
config = make_config(exclude_disabled=False)
|
||||||
|
assert mapping.should_include_user(make_user(enabled=False), config) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclude_service_accounts():
|
||||||
|
config = make_config(exclude_service_accounts=True)
|
||||||
|
assert mapping.should_include_user(make_user(username="service-account-foo"), config) is False
|
||||||
|
assert mapping.should_include_user(make_user(username="bot.svc"), config) is False
|
||||||
|
assert mapping.should_include_user(make_user(username="jdoe"), config) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_keep_service_accounts_when_not_excluding():
|
||||||
|
config = make_config(exclude_service_accounts=False)
|
||||||
|
assert mapping.should_include_user(make_user(username="service-account-foo"), config) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_include_filter():
|
||||||
|
config = make_config(group_include=["ACME-*"], group_exclude=[])
|
||||||
|
assert mapping.should_include_group(make_group(name="ACME-bitbucket-read"), config) is True
|
||||||
|
assert mapping.should_include_group(make_group(name="OTHER-team"), config) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_exclude_filter():
|
||||||
|
config = make_config(group_include=[], group_exclude=["*-admin"])
|
||||||
|
assert mapping.should_include_group(make_group(name="ACME-srm-admin"), config) is False
|
||||||
|
assert mapping.should_include_group(make_group(name="ACME-srm-read"), config) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_include_empty_means_all():
|
||||||
|
config = make_config(group_include=[], group_exclude=[])
|
||||||
|
assert mapping.should_include_group(make_group(name="anything"), config) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_exclude_takes_priority_over_include():
|
||||||
|
config = make_config(group_include=["ACME-*"], group_exclude=["*-secret"])
|
||||||
|
assert mapping.should_include_group(make_group(name="ACME-secret"), config) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# user_entry_attributes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_entry_attributes_full():
|
||||||
|
config = make_config(
|
||||||
|
base_dn="dc=mgmt,dc=example,dc=com",
|
||||||
|
home_base="/home",
|
||||||
|
default_shell="/bin/bash",
|
||||||
|
)
|
||||||
|
resolver = FakeUsernameResolver()
|
||||||
|
group_dns = ["cn=ACME-bitbucket-read,ou=groups,dc=mgmt,dc=example,dc=com"]
|
||||||
|
attrs = mapping.user_entry_attributes(make_user(), "jdoe", group_dns, config, resolver)
|
||||||
|
|
||||||
|
assert attrs["objectClass"] == [
|
||||||
|
"top",
|
||||||
|
"person",
|
||||||
|
"organizationalPerson",
|
||||||
|
"inetOrgPerson",
|
||||||
|
"posixAccount",
|
||||||
|
"shadowAccount",
|
||||||
|
]
|
||||||
|
assert attrs["uid"] == ["jdoe"]
|
||||||
|
assert attrs["cn"] == ["Jane Doe"]
|
||||||
|
assert attrs["sn"] == ["Doe"]
|
||||||
|
assert attrs["givenName"] == ["Jane"]
|
||||||
|
assert attrs["mail"] == ["jane.doe@example.com"]
|
||||||
|
assert attrs["displayName"] == ["Jane Doe"]
|
||||||
|
assert attrs["entryUUID"] == ["11111111-1111-1111-1111-111111111111"]
|
||||||
|
assert attrs["homeDirectory"] == ["/home/jdoe"]
|
||||||
|
assert attrs["loginShell"] == ["/bin/bash"]
|
||||||
|
assert attrs["memberOf"] == group_dns
|
||||||
|
# RDN attribute carries the rdn value.
|
||||||
|
assert attrs["uid"] == ["jdoe"]
|
||||||
|
# All values are list[str].
|
||||||
|
for key, value in attrs.items():
|
||||||
|
assert isinstance(value, list), key
|
||||||
|
assert all(isinstance(v, str) for v in value), key
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_entry_never_includes_userpassword():
|
||||||
|
config = make_config()
|
||||||
|
resolver = FakeUsernameResolver()
|
||||||
|
user = make_user()
|
||||||
|
user["credentials"] = [{"value": "secret"}]
|
||||||
|
user["attributes"] = {"userPassword": ["hunter2"]}
|
||||||
|
attrs = mapping.user_entry_attributes(user, "jdoe", [], config, resolver)
|
||||||
|
assert "userPassword" not in attrs
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_entry_cn_fallback_to_username():
|
||||||
|
config = make_config()
|
||||||
|
resolver = FakeUsernameResolver()
|
||||||
|
user = make_user(firstName="", lastName="")
|
||||||
|
attrs = mapping.user_entry_attributes(user, "jdoe", [], config, resolver)
|
||||||
|
assert attrs["cn"] == ["jdoe"]
|
||||||
|
# No first/last name -> givenName, sn, displayName omitted.
|
||||||
|
assert "givenName" not in attrs
|
||||||
|
assert "sn" not in attrs
|
||||||
|
assert "displayName" not in attrs
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_entry_omits_missing_attrs():
|
||||||
|
config = make_config()
|
||||||
|
resolver = FakeUsernameResolver()
|
||||||
|
user = make_user(email="", firstName="", lastName="")
|
||||||
|
attrs = mapping.user_entry_attributes(user, "jdoe", [], config, resolver)
|
||||||
|
assert "mail" not in attrs
|
||||||
|
assert "givenName" not in attrs
|
||||||
|
assert "sn" not in attrs
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_entry_no_member_groups_omits_memberof():
|
||||||
|
config = make_config()
|
||||||
|
resolver = FakeUsernameResolver()
|
||||||
|
attrs = mapping.user_entry_attributes(make_user(), "jdoe", [], config, resolver)
|
||||||
|
assert "memberOf" not in attrs
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_entry_cn_resolver_sets_cn_rdn():
|
||||||
|
config = make_config()
|
||||||
|
resolver = FakeCNResolver()
|
||||||
|
user = make_user(attributes={"certCN": ["Jane Doe"]})
|
||||||
|
attrs = mapping.user_entry_attributes(user, "Jane Doe", [], config, resolver)
|
||||||
|
# cn is both the derived display cn and the RDN attribute -> single list value.
|
||||||
|
assert attrs["cn"] == ["Jane Doe"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# group_entry_attributes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_entry_with_members():
|
||||||
|
config = make_config(base_dn="dc=mgmt,dc=example,dc=com")
|
||||||
|
member_rdns = ["jdoe", "asmith"]
|
||||||
|
member_dns = [
|
||||||
|
"uid=jdoe,ou=people,dc=mgmt,dc=example,dc=com",
|
||||||
|
"uid=asmith,ou=people,dc=mgmt,dc=example,dc=com",
|
||||||
|
]
|
||||||
|
attrs = mapping.group_entry_attributes(make_group(), member_rdns, member_dns, config)
|
||||||
|
assert "groupOfNames" in attrs["objectClass"]
|
||||||
|
assert attrs["objectClass"][:2] == ["top", "posixGroup"]
|
||||||
|
assert attrs["cn"] == ["ACME-bitbucket-read"]
|
||||||
|
assert attrs["memberUid"] == member_rdns
|
||||||
|
assert attrs["member"] == member_dns
|
||||||
|
assert "gidNumber" in attrs
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_entry_empty_omits_groupofnames_and_member():
|
||||||
|
config = make_config()
|
||||||
|
attrs = mapping.group_entry_attributes(make_group(), [], [], config)
|
||||||
|
assert attrs["objectClass"] == ["top", "posixGroup"]
|
||||||
|
assert "groupOfNames" not in attrs["objectClass"]
|
||||||
|
assert "member" not in attrs
|
||||||
|
assert "memberUid" not in attrs
|
||||||
|
assert "gidNumber" in attrs
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_entry_values_are_str_lists():
|
||||||
|
config = make_config()
|
||||||
|
attrs = mapping.group_entry_attributes(make_group(), ["jdoe"], ["uid=jdoe,..."], config)
|
||||||
|
for key, value in attrs.items():
|
||||||
|
assert isinstance(value, list), key
|
||||||
|
assert all(isinstance(v, str) for v in value), key
|
||||||
194
ldap-gateway/tests/test_server.py
Normal file
194
ldap-gateway/tests/test_server.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""Light wiring tests for :func:`ldap_gateway.server.main`.
|
||||||
|
|
||||||
|
These assert that ``main()`` composes the pieces and registers its listeners and
|
||||||
|
refresh loop, *without* starting a real reactor or touching the network. Every
|
||||||
|
external dependency (config load, KC client, cache, holder, factory, reactor,
|
||||||
|
``LoopingCall``, ``deferToThread``) is monkeypatched.
|
||||||
|
|
||||||
|
``directory.py`` and ``handlers.py`` may be landing concurrently; if the server
|
||||||
|
module (or its imports) is not importable yet, the suite skips rather than
|
||||||
|
failing the build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .conftest import make_config
|
||||||
|
|
||||||
|
# The server module pulls in directory/handlers which may not exist yet while the
|
||||||
|
# slice lands concurrently. Skip the whole module if so.
|
||||||
|
server = pytest.importorskip("ldap_gateway.server")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeReactor:
|
||||||
|
"""Records listener registrations and whether run() was invoked."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.tcp: list[tuple] = []
|
||||||
|
self.ssl: list[tuple] = []
|
||||||
|
self.ran = False
|
||||||
|
|
||||||
|
def listenTCP(self, port, factory, interface="", **kw): # noqa: N802
|
||||||
|
self.tcp.append((port, factory, interface))
|
||||||
|
|
||||||
|
def listenSSL(self, port, factory, context, interface="", **kw): # noqa: N802
|
||||||
|
self.ssl.append((port, factory, context, interface))
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.ran = True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLoopingCall:
|
||||||
|
"""Captures the scheduled callable and start() args; never fires."""
|
||||||
|
|
||||||
|
instances: list["_FakeLoopingCall"] = []
|
||||||
|
|
||||||
|
def __init__(self, fn):
|
||||||
|
self.fn = fn
|
||||||
|
self.started_with = None
|
||||||
|
_FakeLoopingCall.instances.append(self)
|
||||||
|
|
||||||
|
def start(self, interval, now=True):
|
||||||
|
self.started_with = (interval, now)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCache:
|
||||||
|
"""Stand-in cache; counts refresh() calls and can be made to fail."""
|
||||||
|
|
||||||
|
def __init__(self, fail: bool = False):
|
||||||
|
self.fail = fail
|
||||||
|
self.refresh_calls = 0
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.refresh_calls += 1
|
||||||
|
if self.fail:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return "SNAPSHOT"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHolder:
|
||||||
|
def __init__(self):
|
||||||
|
self.swapped: list = []
|
||||||
|
|
||||||
|
def swap(self, snapshot):
|
||||||
|
self.swapped.append(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_common(monkeypatch, config, cache, holder):
|
||||||
|
"""Patch every external dependency main() touches. Returns the fake reactor."""
|
||||||
|
fake_reactor = _FakeReactor()
|
||||||
|
_FakeLoopingCall.instances.clear()
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "load_config", lambda: config)
|
||||||
|
monkeypatch.setattr(server, "get_identity_resolver", lambda c: object())
|
||||||
|
monkeypatch.setattr(server, "KeycloakClient", lambda c: object())
|
||||||
|
monkeypatch.setattr(server, "KeycloakCache", lambda client, c: cache)
|
||||||
|
|
||||||
|
def _holder_factory(c, resolver):
|
||||||
|
captured["holder_config"] = c
|
||||||
|
return holder
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "DirectoryHolder", _holder_factory)
|
||||||
|
|
||||||
|
def _factory(h, c):
|
||||||
|
captured["factory_args"] = (h, c)
|
||||||
|
return f"FACTORY({id(h)})"
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "LDAPServerFactory", _factory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "reactor", fake_reactor)
|
||||||
|
monkeypatch.setattr(server, "LoopingCall", _FakeLoopingCall)
|
||||||
|
# deferToThread should never actually run during these tests; give it a no-op
|
||||||
|
# that returns a Deferred-like object exposing addCallbacks.
|
||||||
|
monkeypatch.setattr(server, "deferToThread", lambda fn, *a, **k: _FakeDeferred())
|
||||||
|
|
||||||
|
return fake_reactor, captured
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDeferred:
|
||||||
|
def addCallbacks(self, cb, eb):
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_wires_plain_listener_and_refresh_loop(monkeypatch):
|
||||||
|
"""No TLS configured: one TCP listener, no SSL, refresh loop scheduled, warm done."""
|
||||||
|
config = make_config(tls_cert=None, tls_key=None, port=3389, refresh_interval=42)
|
||||||
|
cache = _FakeCache()
|
||||||
|
holder = _FakeHolder()
|
||||||
|
fake_reactor, captured = _patch_common(monkeypatch, config, cache, holder)
|
||||||
|
|
||||||
|
server.main()
|
||||||
|
|
||||||
|
# Initial warm attempted and swapped in.
|
||||||
|
assert cache.refresh_calls == 1
|
||||||
|
assert holder.swapped == ["SNAPSHOT"]
|
||||||
|
|
||||||
|
# Plain listener registered on the configured port/interface; no SSL.
|
||||||
|
assert len(fake_reactor.tcp) == 1
|
||||||
|
port, factory, interface = fake_reactor.tcp[0]
|
||||||
|
assert port == 3389
|
||||||
|
assert interface == config.bind_host
|
||||||
|
assert fake_reactor.ssl == []
|
||||||
|
|
||||||
|
# Factory built from the holder + config.
|
||||||
|
assert captured["factory_args"] == (holder, config)
|
||||||
|
|
||||||
|
# Background refresh scheduled (now=False) on the configured interval.
|
||||||
|
assert len(_FakeLoopingCall.instances) == 1
|
||||||
|
assert _FakeLoopingCall.instances[0].started_with == (42, False)
|
||||||
|
|
||||||
|
# Reactor actually started.
|
||||||
|
assert fake_reactor.ran is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_registers_ldaps_when_tls_configured(monkeypatch, tmp_path):
|
||||||
|
"""With cert+key set, an SSL listener is registered on the LDAPS port."""
|
||||||
|
cert = tmp_path / "tls.crt"
|
||||||
|
key = tmp_path / "tls.key"
|
||||||
|
cert.write_text("cert")
|
||||||
|
key.write_text("key")
|
||||||
|
config = make_config(tls_cert=str(cert), tls_key=str(key), ldaps_port=6636)
|
||||||
|
cache = _FakeCache()
|
||||||
|
holder = _FakeHolder()
|
||||||
|
|
||||||
|
# Avoid real OpenSSL context creation; just capture the args.
|
||||||
|
made = {}
|
||||||
|
|
||||||
|
def _fake_ctx(key_path, cert_path):
|
||||||
|
made["args"] = (key_path, cert_path)
|
||||||
|
return "CTX"
|
||||||
|
|
||||||
|
fake_reactor, _ = _patch_common(monkeypatch, config, cache, holder)
|
||||||
|
monkeypatch.setattr(server.ssl, "DefaultOpenSSLContextFactory", _fake_ctx)
|
||||||
|
|
||||||
|
server.main()
|
||||||
|
|
||||||
|
assert len(fake_reactor.ssl) == 1
|
||||||
|
port, factory, context, interface = fake_reactor.ssl[0]
|
||||||
|
assert port == 6636
|
||||||
|
assert context == "CTX"
|
||||||
|
# CONTRACT: key path first, cert path second.
|
||||||
|
assert made["args"] == (str(key), str(cert))
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_continues_when_initial_warm_fails(monkeypatch):
|
||||||
|
"""A failing initial refresh must not stop startup; server still listens + runs."""
|
||||||
|
config = make_config(tls_cert=None, tls_key=None)
|
||||||
|
cache = _FakeCache(fail=True)
|
||||||
|
holder = _FakeHolder()
|
||||||
|
fake_reactor, _ = _patch_common(monkeypatch, config, cache, holder)
|
||||||
|
|
||||||
|
# Should not raise despite refresh() blowing up.
|
||||||
|
server.main()
|
||||||
|
|
||||||
|
assert cache.refresh_calls == 1
|
||||||
|
# Nothing swapped in (warm failed) but the holder retains its empty tree.
|
||||||
|
assert holder.swapped == []
|
||||||
|
# Server still came up.
|
||||||
|
assert len(fake_reactor.tcp) == 1
|
||||||
|
assert fake_reactor.ran is True
|
||||||
|
# Refresh loop still scheduled for later recovery.
|
||||||
|
assert len(_FakeLoopingCall.instances) == 1
|
||||||
Reference in New Issue
Block a user