Compare commits

...

2 Commits

Author SHA1 Message Date
35a788865e Add LDAP gateway install & configuration guide
Operator-facing docs/ldap-gateway.md: prerequisites, local run, building,
full env-var reference, identity swapping, Helm deployment, and ops notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:38:47 -07:00
f4bd03dc52 Add read-only LDAP gateway microservice backed by Keycloak
New ldap-gateway/ service (Twisted + ldaptor) that exposes Keycloak users
and groups over LDAP v3 for legacy apps and Linux hosts (SSSD/NSS).

Design (see ldap-gateway/SPEC.md):
- Reads directly from the KC Admin API with an in-memory TTL cache +
  background refresh; fully stateless, no DB or queue.
- Service-bind read-only: only configured service accounts may bind; no
  end-user password auth. Writes return unwillingToPerform.
- Serves POSIX + inetOrgPerson entries (uid/gid/home, memberOf, group
  memberUid/member). UID/GID from a KC custom attribute else derived from
  the stable KC UUID.
- Pluggable IdentityResolver (LDAP_IDENTITY_SOURCE): username today, a
  cert_cn strategy stubbed for the future cert-CN-as-identity direction.

Build/deploy:
- build.sh builds and (optionally) pushes mgmt-ldap-gateway alongside
  backend/frontend.
- Helm: ldap-gateway deployment/service/configmap, gated by
  ldapGateway.enabled (off by default), reusing the shared KC secret.

Verified end to end against a live Keycloak: service bind, user/group
search, anonymous + bad-password denial, write rejection; container image
builds, runs as non-root, warms from KC, and serves searches. 125 tests
pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:37:20 -07:00
36 changed files with 4999 additions and 4 deletions

View File

@@ -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

View File

@@ -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 -}}

View 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 }}

View 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 }}

View 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 }}

View File

@@ -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
View 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 |

View 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
View 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
View 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
View 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
View 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 `100000599999`) 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.
```

View File

@@ -0,0 +1,3 @@
"""ldap_gateway — LDAP v3 gateway exposing Keycloak identity data (read-only)."""
__version__ = "0.1.0"

View 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()

View 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

View 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,
)

View 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"),
)

View 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

View 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)

View 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')")

View 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

View 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

View 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()

View 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
View 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

View File

View 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()

View 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
)

View 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

View 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]

View 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)") == []

View 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

View 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"))

View 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()

View 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

View 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