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}"
|
||||
FRONTEND_IMAGE="${REGISTRY}/mgmt-frontend:${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
|
||||
BACKEND_IMAGE="mgmt-backend:${TAG}"
|
||||
BACKEND_IMAGE_DATED="mgmt-backend:${DATE_TAG}"
|
||||
FRONTEND_IMAGE="mgmt-frontend:${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
|
||||
|
||||
echo "Building Management Suite containers..."
|
||||
@@ -25,6 +29,8 @@ echo " Backend: ${BACKEND_IMAGE}"
|
||||
echo " ${BACKEND_IMAGE_DATED}"
|
||||
echo " Frontend: ${FRONTEND_IMAGE}"
|
||||
echo " ${FRONTEND_IMAGE_DATED}"
|
||||
echo " LDAP Gateway: ${LDAP_GATEWAY_IMAGE}"
|
||||
echo " ${LDAP_GATEWAY_IMAGE_DATED}"
|
||||
echo ""
|
||||
|
||||
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 --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 "Build complete!"
|
||||
echo " ${BACKEND_IMAGE}"
|
||||
echo " ${BACKEND_IMAGE_DATED}"
|
||||
echo " ${FRONTEND_IMAGE}"
|
||||
echo " ${FRONTEND_IMAGE_DATED}"
|
||||
echo " ${LDAP_GATEWAY_IMAGE}"
|
||||
echo " ${LDAP_GATEWAY_IMAGE_DATED}"
|
||||
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
echo ""
|
||||
@@ -59,6 +71,8 @@ if [ -n "$REGISTRY" ]; then
|
||||
podman push "$BACKEND_IMAGE_DATED"
|
||||
podman push "$FRONTEND_IMAGE"
|
||||
podman push "$FRONTEND_IMAGE_DATED"
|
||||
podman push "$LDAP_GATEWAY_IMAGE"
|
||||
podman push "$LDAP_GATEWAY_IMAGE_DATED"
|
||||
echo "Pushed."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -41,6 +41,18 @@ busybox:1.37
|
||||
{{- 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" -}}
|
||||
{{ include "mgmt-suite.fullname" . }}-postgres
|
||||
{{- 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.
|
||||
externalTls: false
|
||||
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