Project Files

ldap-manager-helm
Explorer
values.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# LDAP Manager. The app is stateless except for two directories that are not:
#   /app/.data     UI-owned cluster overlay (clusters.yml)
#   /app/.secrets  Fernet key, encrypted bind-password cache, local users, session key
# Both default to a PersistentVolumeClaim. Do not disable them in production:
# losing /app/.secrets invalidates cached passwords and any local accounts.

# One replica is the safe default. See persistence.* and the HA section of the
# README before raising it: the two state directories are ReadWriteOnce by
# default, and sessions are HMAC-signed with a key stored in /app/.secrets.
replicaCount: 1

image:
  # Docker Hub primary, ghcr.io/vibhuvioio/ldap-manager mirror.
  repository: vibhuvioio/ldap-manager
  # Empty = .Chart.AppVersion, which is the LDAP Manager release.
  tag: ""
  pullPolicy: IfNotPresent

imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""

serviceAccount:
  create: true
  annotations: {}
  # Defaults to the fullname.
  name: ""

# ---------------------------------------------------------------- application

# Prefix the app is served under, e.g. /ldap-manager behind
# https://example.com/ldap-manager. Empty means "/".
contextPath: ""

# Comma-separated origins allowed to call the API. Empty leaves the app default
# (http://localhost:5173), which is wrong for anything but a lab. Set this to
# the public origin of the UI, e.g. https://ldap.example.com. Never "*".
allowedOrigins: ""

logLevel: INFO
jsonLogs: true

# Extra environment variables, e.g.
#   - name: VITE_FOOTER_TEXT
#     value: "LDAP Manager"
extraEnv: []
# Extra envFrom sources (ConfigMap/Secret refs).
extraEnvFrom: []

# ------------------------------------------------------------------- config.yml

config:
  # Preferred in GitOps: a ConfigMap you manage outside the release. The chart
  # then renders no ConfigMap and mounts this one instead.
  existingConfigMap: ""
  # Key inside the ConfigMap, and the file name inside config.mountPath.
  key: config.yml
  # Directory the ConfigMap is mounted at, read-only. The Deployment sets
  # LDAP_MANAGER_CONFIG=<mountPath>/<key>. Mounting the directory (not a
  # subPath file) is deliberate: config.yml is re-read on every request, but a
  # subPath mount never sees ConfigMap updates, so an edit would need a restart.
  mountPath: /etc/ldap-manager

  # Verbatim config.yml override, rendered through tpl. When set, the structured
  # auth/clusters/extra values below are ignored.
  content: ""

  auth:
    # none | local | ldap
    #   none   a reverse proxy / SSO already authenticated the user
    #   local  built-in accounts, created in a first-run wizard
    #   ldap   one of the clusters below authenticates users
    mode: none
    # Role for a visitor who is not signed in. readonly | readwrite | admin
    defaultRole: readonly
    sessionLifetimeHours: 12
    # Free-form block used only when mode: ldap. Mirrors auth.ldap from
    # config.example.yml, e.g.
    #   cluster: example-cluster
    #   user_dn_template: "uid={username},ou=People,dc=example,dc=com"
    #   role_map:
    #     default: readonly
    #     groups:
    #       "cn=ldap-admins,ou=Group,dc=example,dc=com": admin
    ldap: {}

  # Clusters to manage. Keys are the config.yml schema verbatim, so a block can
  # be pasted from the app's config.example.yml. Nothing here is invented:
  # replace every placeholder with your own directory's details.
  clusters: []
  #  - name: example-cluster
  #    host: ldap.example.com            # single node; omit `host` when using
  #                                      # `nodes` (exactly one, never both)
  #    # nodes:                          # multi-master cluster
  #    #   - host: ldap1.example.com
  #    #     port: 389
  #    #   - host: ldap2.example.com
  #    #     port: 389
  #    port: 389
  #    bind_dn: cn=admin,dc=example,dc=com
  #    base_dn: dc=example,dc=com
  #    description: Example LDAP cluster
  #    readonly: false
  #    # stored (default) | env | file | prompt | config
  #    # env reads $LDAP_MANAGER_CLUSTER_EXAMPLE_CLUSTER_PASSWORD, i.e. the
  #    # cluster name upper-cased with every non-alphanumeric run replaced by _.
  #    credential:
  #      source: env
  #    # tls:
  #    #   mode: ldaps                   # none | ldaps | starttls
  #    #   ca_file: /etc/ldap-manager-certs/ca.crt
  #    #   verify: true
  #    # config:
  #    #   bind_dn: cn=config            # needs its own credential, source: env
  #    #   credential:
  #    #     source: env                 # $LDAP_MANAGER_CONFIG_EXAMPLE_CLUSTER_PASSWORD
  #    # user_creation_form:
  #    #   base_ou: ou=People,dc=example,dc=com

  # Appended verbatim to the rendered config.yml, for keys the structured values
  # above do not model.
  extra: ""

# ------------------------------------------------------------------- secrets

secrets:
  # Preferred: an existing Secret. Every key becomes an environment variable, so
  # a key named LDAP_MANAGER_CLUSTER_<NAME>_PASSWORD reaches the app without the
  # chart ever seeing the value.
  #
  #   kubectl -n directory create secret generic ldap-manager-secrets \
  #     --from-literal=LDAP_MANAGER_CLUSTER_EXAMPLE_CLUSTER_PASSWORD='...' \
  #     --from-literal=LDAP_MANAGER_CONFIG_EXAMPLE_CLUSTER_PASSWORD='...' \
  #     --from-literal=LDAP_MANAGER_SECRET_KEY="$(openssl rand -hex 32)"
  existingSecret: ""

  # Chart-managed Secret, used only when existingSecret is empty. Convenient for
  # a lab; the values still live in `helm get values` / the release Secret.
  create: false
  env: {}
  #  LDAP_MANAGER_CLUSTER_EXAMPLE_CLUSTER_PASSWORD: "change-me"
  #  LDAP_MANAGER_CONFIG_EXAMPLE_CLUSTER_PASSWORD: "change-me"
  #  # Set this to make sessions verify across replicas without sharing /app/.secrets.
  #  LDAP_MANAGER_SECRET_KEY: "change-me"

  # When set, the Secret above is also mounted read-only at this path, for
  # clusters using credential.source: file. Paths then look like
  # /etc/ldap-manager-secrets/example-cluster.
  mountPath: ""

# ------------------------------------------------------------------- persistence

# Claims the chart renders carry `helm.sh/resource-policy: keep`, so
# `helm uninstall` leaves them (and the data) behind instead of deleting them
# with the rest of the release. Losing /app/.secrets would invalidate the
# encrypted bind-password cache, the Fernet key, any built-in local accounts and
# the session signing key. Remove a claim deliberately with
# `kubectl delete pvc <release>-ldap-manager-data`.
#
# A reinstall under the SAME release name and namespace adopts the kept claim, so
# the data survives an uninstall/reinstall cycle. A different release name whose
# rendered names collide (a fullnameOverride, typically) is refused by Helm's
# ownership check - delete the claim first, or keep the release name.
persistence:
  # /app/.data - clusters added or edited in the UI (clusters.yml).
  data:
    enabled: true
    # Use a claim you created yourself instead of one the chart creates.
    existingClaim: ""
    size: 1Gi
    accessModes: ["ReadWriteOnce"]
    # Empty = the cluster default StorageClass.
    storageClass: ""
    annotations: {}
  # /app/.secrets - Fernet key, encrypted password cache, local users, session key.
  secrets:
    enabled: true
    existingClaim: ""
    size: 1Gi
    accessModes: ["ReadWriteOnce"]
    storageClass: ""
    annotations: {}
  # /app/.cache - encrypted password cache. Rebuilt on demand, so an emptyDir is
  # the default; a lost cache only means users re-enter a bind password. Enable
  # it if you would rather not.
  cache:
    enabled: false
    existingClaim: ""
    size: 1Gi
    accessModes: ["ReadWriteOnce"]
    storageClass: ""
    annotations: {}

# ------------------------------------------------------------------- networking

service:
  type: ClusterIP
  port: 8000
  annotations: {}

ingress:
  enabled: false
  className: ""
  annotations: {}
  #  nginx.ingress.kubernetes.io/proxy-body-size: 16m
  hosts: []
  #  - host: ldap-manager.example.com
  #    paths:
  #      - path: /
  #        pathType: Prefix
  tls: []
  #  - secretName: ldap-manager-tls
  #    hosts:
  #      - ldap-manager.example.com

# ------------------------------------------------------------------- probes

# The app has two useful endpoints and they mean different things:
#   /api/auth/status  200 whenever the process is serving. App-local.
#   /health           200 only when config.yml parses AND every checked LDAP
#                     cluster answers. "degraded" (a managed directory being
#                     down) returns 503.
# Defaults probe the app-local endpoint on purpose: with one replica, readiness
# on /health removes the only pod from the Service during an LDAP outage, so the
# UI you would use to diagnose it becomes unreachable. Point readiness.path (and
# liveness.path) at /health only when you want traffic gated on LDAP health and
# you run more than one replica.
probes:
  startup:
    enabled: true
    path: /api/auth/status
    failureThreshold: 30
    periodSeconds: 5
  readiness:
    enabled: true
    path: /api/auth/status
    initialDelaySeconds: 5
    periodSeconds: 10
    timeoutSeconds: 5
    failureThreshold: 3
  liveness:
    enabled: true
    path: /api/auth/status
    initialDelaySeconds: 20
    periodSeconds: 30
    timeoutSeconds: 5
    failureThreshold: 3

# ------------------------------------------------------------------- rollout

# maxSurge 0 is deliberate: the state volumes are ReadWriteOnce, so a surge pod
# would fight the old one for the same claim. The old pod stops before the new
# one starts.
#
# Consequence worth knowing before you trust a CI gate: Helm computes
#     expectedReady = replicas - maxUnavailable
# for a Deployment, so with one replica and maxUnavailable 1 that is 0 and
# `helm install --wait` / `helm upgrade --wait` return as soon as the ReplicaSet
# exists - before the pod is Ready. `--atomic` cannot roll a bad release back
# either. Gate on `kubectl rollout status deploy/<release>` instead, which is
# what hack/e2e-local.sh and the workflows do.
deploymentStrategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 0
    maxUnavailable: 1

terminationGracePeriodSeconds: 30

# ------------------------------------------------------------------- security

# The image is designed to run unprivileged. If your tag runs as a different
# uid, override runAsUser/runAsGroup/fsGroup together - they must match, because
# the app writes 0600 files inside the volumes.
podSecurityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  fsGroup: 1000
  fsGroupChangePolicy: OnRootMismatch
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
  # False by default: Python may want to write bytecode caches under /app.
  readOnlyRootFilesystem: false
  seccompProfile:
    type: RuntimeDefault

# ------------------------------------------------------------------- resources

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: "1"
    memory: 1Gi

# Extra volumes/mounts for cluster CA certificates (tls.ca_file) or anything
# else the config references by path.
extraVolumes: []
extraVolumeMounts: []

podAnnotations: {}
podLabels: {}
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []

# At least one pod keeps serving during a node drain. No effect at one replica.
podDisruptionBudget:
  enabled: true
  maxUnavailable: 1
YAMLUTF-8
Ln 3192 files
ℹ️

Note: values.schema.json is enforced by Helm. An unknown value name is still accepted, but a value of the wrong type or outside an enum fails the install before anything is created.


Install

helm repo add vibhuvioio https://VibhuviOiO.github.io/ldap-manager-helmchart
helm repo update
helm search repo vibhuvioio

helm install ldap-manager vibhuvioio/ldap-manager \
  --namespace directory --create-namespace \
  --set config.content="$(cat config.yml)"

The chart is published on Artifact Hub, signed with GPG provenance. Verify before installing if you want to:

helm verify ldap-manager-0.2.0.tgz

The three things you must supply

The chart cannot guess any of them, and it fails loudly rather than deploying something insecure.

WhatWhereWhy
config.ymlconfig.content or config.existingConfigMapClusters are declared in the file only. There is no runtime cluster API.
Cluster bind passwordssecrets.existingSecret or extraEnvThe app never stores them; they must arrive as env or file.
auth.modeinside config.contentnone serves anonymous traffic at defaultRole; local and ldap require a login.

config.yml

Inline for a first install:

helm install ldap-manager vibhuvioio/ldap-manager \
  -n directory --create-namespace \
  --set-file config.content=./config.yml

Or reference a ConfigMap you manage separately, which is what I would do in production so config.yml lives in Git and not in a release value:

kubectl create configmap ldap-manager-config \
  --from-file=config.yml -n directory

helm install ldap-manager vibhuvioio/ldap-manager \
  -n directory \
  --set config.existingConfigMap=ldap-manager-config

Cluster passwords

One environment variable per cluster, named LDAP_MANAGER_CLUSTER__PASSWORD in upper case:

kubectl create secret generic ldap-manager-clusters -n directory \
  --from-literal=LDAP_MANAGER_CLUSTER_VIBHUVIOIO_PASSWORD='...' \
  --from-literal=LDAP_MANAGER_CLUSTER_ACMECORP_PASSWORD='...'

helm install ldap-manager vibhuvioio/ldap-manager -n directory \
  --set secrets.existingSecret=ldap-manager-clusters

The variable is the cluster's name from config.yml, upper-cased, with any non-alphanumeric character replaced by _. A cluster named oiocloud uses LDAP_MANAGER_CLUSTER_OIOCLOUD_PASSWORD.

Persistence, and the one constraint that bites

Two volumes carry state:

VolumeHoldsOn helm uninstall
/app/.datathe audit logdeleted
/app/.secretssession signing key, local accountskept (resource-policy: keep)
/app/.secrets earns that annotation because losing it invalidates every session and every auth.mode: local account. It is the one volume you cannot regenerate.

Both default to ReadWriteOnce, which means replicaCount must stay 1. Two pods on different nodes cannot mount the same RWO claim, so the chart refuses to render above one replica while the access modes are RWO:

Error: execution error at (ldap-manager/templates/...): replicaCount > 1 requires ReadWriteMany volumes

To run more than one replica you need RWX storage and shared state:

replicaCount: 2
persistence:
  data:
    accessModes: [ReadWriteMany]
  secrets:
    accessModes: [ReadWriteMany]
ℹ️

Note: With two replicas the audit log is written by both pods to one volume. That is safe (append-only JSONL) but the ordering between pods is not guaranteed.


Ingress

The UI speaks plain HTTP. Terminate TLS at the ingress.

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: ldap-manager.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: ldap-manager-tls
      hosts:
        - ldap-manager.example.com

If a proxy does not strip the prefix, tell the app about it:

contextPath: /ldap-manager

Verify the install

kubectl -n directory get pods
kubectl -n directory port-forward svc/ldap-manager 8000:8000

curl -s localhost:8000/api/auth/status
curl -s localhost:8000/api/clusters/list

Both must answer. api/clusters/list returning an empty list means the ConfigMap did not mount - check kubectl -n directory describe pod.

kubectl -n directory exec deploy/ldap-manager -- ls /app/app/api/
Twelve modules - aci, audit, auth, backup, capabilities, clusters, dashboard, entries, ldif, logs, monitoring, schema. Six means you are running an image from before authentication existed, which is worth knowing before you point it at a directory.

Upgrade

helm repo update
helm upgrade ldap-manager vibhuvioio/ldap-manager -n directory

Helm validates values.schema.json on upgrade as well as install, so a value that was accepted before a chart bump can start failing. Read the chart's artifacthub.io/changes annotation before upgrading in production.

What the UI cannot see from here

Some features need optional server-side configuration on your OpenLDAP servers, and the app detects the gap rather than erroring:

FeatureNeedsIf missing
Monitoring, replication topologycn=Monitor (monitor backend)the view explains how to enable it
Server-side change logaccesslog overlay, queried over LDAPthe Activity page shows the enablement LDIF
The enablement snippets are shown in the UI itself, against your server. Nothing depends on running this container image for OpenLDAP - point it at any OpenLDAP and it adapts.