Explains what the image does, which variables are required (five),
usually set, or already defaulted to production, and lists the secrets.
Then deployment three ways: plain docker run with SQLite on a volume,
Docker Compose with PostgreSQL, and Kubernetes with Helm, with
configuration injected as environment and migrations as a separate
pre-install/pre-upgrade Job. Ends with checks after deploying and a
troubleshooting table. Linked from the README.
PostgreSQL with a persistent volume, a one-off migrate service running
migrate_locked, and the web app, which starts only after migrations
succeeded. The app runs with a read-only filesystem, no capabilities and
a health check. Configuration comes from .env (or ENV_FILE). Checked end
to end: database healthy, migrations applied, both probes OK, login
works.
Without DATABASE_HOST the app uses SQLite, which defaulted to
/app/db.sqlite3: inside the image, not writable by uid 10001, and
unusable with a read-only root filesystem. The image now has a /data
directory owned by the app user, declared as a volume, and SQLITE_PATH
defaults to /data/db.sqlite3. A fresh named volume mounted there is
writable. Checked with docker run --read-only: migrations, the app, and
a restart all keep the database.
It opens with the five required variables, then what production
usually sets (proxy header, PostgreSQL, mail relay, OTP), then every
optional setting commented out at its default. It lists exactly the 51
variables the code reads.
collectstatic only needs the five required settings now, so the build
passes placeholders for that one command instead of bind-mounting
dev/env.dev. The build context no longer needs dev/ at all. Also point
the header comment at migrate_locked and keep compose.yaml out of the
build context.
Without OTPSERVER, new accounts get no OTP seed, the seed page says OTP
is not configured, and the seed API returns 503. Everything else works
as before. If OTPSERVER is set, ADMIN_SEED, ADMIN_NAME, ADMIN_REALM
and USER_REALM are required and startup fails without them.
Fewer variables are required to deploy. The directory layout now derives
from ENTIRE_SEARCH_BASE (default dc=ungleich,dc=ch): LDAP_CUSTOMER_DN
defaults to ou=customer under it and LDAPSEARCH to ou=customer plus
ou=users. A different tree, such as the test server, only sets
ENTIRE_SEARCH_BASE. LDAP_CUSTOMER_GROUP_ID (10004) and
LDAP_DEFAULT_START_UID (10000) default to production's values, and
ALLOWED_HOSTS to account.ungleich.ch.
CSRF_TRUSTED_ORIGINS defaults to https:// plus each allowed host, which
is what a site behind an HTTPS ingress needs. LDAPSERVER deliberately
has no default, so a test deployment cannot reach production by
accident.
The test helpers hardcoded dc=ungleich,dc=ch. They now build DNs from
ENTIRE_SEARCH_BASE, so the suite can run against ungleich's test server
(dc=my-domain,dc=com) by overriding settings through the environment.
The TLS tests depend on the local dev LDAP and its CA, so they skip
themselves when LDAPSERVER points elsewhere.
New customer entries always got ldapPublicKey, which needs the
openssh-lpk schema. Production has it; ungleich's test server only
loads core, cosine, inetorgperson and nis, so registration failed
there. LDAP_USER_OBJECT_CLASSES (space separated) defaults to the
production set, inetOrgPerson posixAccount ldapPublicKey, and can drop
ldapPublicKey for servers without that schema.
Sample entries from production show:
- customers live in ou=customer (singular), not ou=customers
- customers have gidNumber 10004 and staff 10002; uidNumbers are in
the 10000s
- staff entries list organizationalPerson and person explicitly
- the servers serve ldaps:// on port 636
The app reads all of these from settings, so its behaviour is
unchanged; the customer entry it creates already matches production
attribute for attribute. The defaults and test setups were wrong:
env.sample, the Kubernetes ConfigMap (now ldaps:// without StartTLS,
gid 10004, start uid 10000), dev/env.dev and the dev LDAP seed now
mirror production. The made-up ou=groups is gone. The tests take the
customer DN and gidNumber from settings instead of hardcoding them.
The deployment guide now notes that the app is dual-stack and tested on
an IPv6-only cluster, shows how to mount a private LDAP CA and set
LDAP_CA_FILE, and explains how to run the kind dev overlay as an
IPv6-only cluster.
ldap3 does not verify TLS certificates by default, and the app never
configured it to. ldaps:// and StartTLS were encrypted but accepted any
certificate, so anyone able to intercept LDAP traffic could pose as the
server and collect the admin password and every password used to log in.
All LDAP servers now use VerifiedTls, which leaves verification to
Python's ssl module: certificate chain and hostname, IP literals
included (ldap3's own matcher cannot handle IPv6 literals). It also
sends SNI and requires TLS 1.2 or newer. LDAP_CA_FILE adds a CA bundle
for a private CA. LDAP_TLS_VERIFY=False turns verification off, with a
logged warning. A failed StartTLS now always raises instead of
continuing in plaintext.
New tests against the dev LDAP cover ldaps, StartTLS and ldaps over an
IPv6 literal with the CA (and check the socket is encrypted), plain LDAP
over IPv6, rejection of an untrusted certificate (ldaps and StartTLS),
rejection of a hostname mismatch, and the opt-out.
Production and the client's test server use ldaps://, so the dev server
now offers TLS as well: ldaps on 636 (localhost:3636) and StartTLS on
389, with both ports also published on [::1].
A throwaway CA and server certificate are generated on every start, with
the full CA extensions that Python 3.13+ strict verification requires.
The CA certificate is copied to dev/ldap/export/ca.crt (gitignored).
The server certificate covers localhost, the service names and ::1, but
not 127.0.0.1, so tests can show that hostname mismatches are
rejected.
gunicorn bound to 0.0.0.0:8000, which is IPv4 only. On an IPv6-only
network (ungleich's clusters are IPv6 first) every request and probe
failed. [::] is dual-stack on Linux. Checked on an IPv6-only and on an
IPv4-only Docker network: both probes answer, and migrations reach
PostgreSQL by hostname over IPv6.
Replace the v2 notes and the broken setup steps with what the app does,
how to develop and test against the dev LDAP, where configuration is
documented, how to deploy, and a map of the code.
deploy/k8s/dev runs the base with 2 replicas alongside throwaway
services: two independent OpenLDAP servers (the dev image), PostgreSQL
18 and Mailpit.
deploy/k8s/dev/e2e.sh checks a running deployment: both probes,
registration with the activation mail sent over SMTP (and no password
in it), activation, login, and login and readiness while ldap1 is
scaled to zero. All checks pass.
A kustomize base with a Deployment (2 replicas, migrate_locked init
container, liveness, readiness and startup probes, uid 10001 with a
read-only root filesystem, all capabilities dropped, RuntimeDefault
seccomp, no service account token, resource requests and limits), plus
a Service, an Ingress for account.ungleich.ch, a PodDisruptionBudget
and a ConfigMap with every non-secret setting.
The Secret is not part of the base, so credentials stay out of git;
secret.example.yaml and the README show how to create it. The README
also lists what the cluster must provide and what to confirm before
going live (StartTLS on the LDAP servers, HSTS).
Activation created the LDAP account and then requested an OTP seed in
the same try block. If the OTP server was unreachable, the user got an
error page although their account existed, and their next attempt
failed with "username taken". OTP errors are now logged, as non-201
responses already were, and activation still succeeds.
Each pod runs migrations in its init container, and Kubernetes can start
several at once. Django does not guard against concurrent migrate runs.
migrate_locked holds a PostgreSQL advisory lock while migrating; on
SQLite it is plain migrate. With three runs started at once on an empty
database, one applied all migrations and the other two waited, then had
nothing to do.
The old Dockerfile copied the abandoned v2 app and a requirements file
that no longer existed, and needed build tools and a libldap workaround
for python-ldap.
The new image:
- python:3.15-slim (Debian trixie) with security updates applied; no
compiler and no sshd
- runs gunicorn (gthread workers; GUNICORN_* overrides) as uid 10001
- works with a read-only root filesystem and all capabilities dropped
- runs collectstatic at build time; the dev env file is mounted for that
step only and is not stored in the image
- does not run migrations on start; run manage.py migrate once per
release (an init container in Kubernetes)
Checked against the dev LDAP and PostgreSQL containers: migrate, both
health probes, static files and a full login through gunicorn work.
The container runs only gunicorn, with no web server in front of it
inside the pod. WhiteNoise serves the collectstatic output
(STATIC_ROOT, configurable) with gzip compression. It uses the
non-hashed storage because the vendor CSS references source maps that
are not shipped. All 20 static files on the landing page are served.
Adds gunicorn and whitenoise to the requirements.
They are served by the first middleware, so probes, which use the pod
IP as Host, are not rejected by ALLOWED_HOSTS or redirected to HTTPS.
/healthz only shows the process is serving, so an LDAP outage never
restarts pods. /readyz returns 503 unless the database answers and an
LDAP server accepts the admin bind.
Sessions, password reset tokens and pending registrations live in the
database, so running more than one replica needs a shared one. With
DATABASE_HOST set the app uses PostgreSQL (psycopg 3 binary wheels, no
system libraries) with persistent, health-checked connections.
Otherwise SQLite is used, at a path configurable with SQLITE_PATH so a
single replica can keep it on a volume.
docker-compose.dev.yml gains a PostgreSQL 18 service on
localhost:5433. The full test suite passes on SQLite and PostgreSQL.
- Session and CSRF cookies are secure-only unless DEBUG is on.
- TRUST_X_FORWARDED_PROTO honours X-Forwarded-Proto from a TLS
terminating ingress. Without it, Django 4+ fails the CSRF origin
check on every form POST.
- CSRF_TRUSTED_ORIGINS, SECURE_SSL_REDIRECT and SECURE_HSTS_SECONDS are
configurable and off by default. HSTS for subdomains and preload are
left to ungleich, since they affect all of *.ungleich.ch.
None of the calls to the OTP server set a timeout, so a hanging OTP
server blocked a web worker indefinitely. Each call now times out after
OTP_TIMEOUT seconds (default 10).
The OTP server variables were read straight from the environment in
views.py. They are now OTP_* settings next to everything else, under
the same environment variable names, so existing deployments are
unaffected.
Remove LDAP_SERVER and LDAP_USERS_DN, which nothing used.
env.sample moves to the repository root (where .env is read from) and
lists every variable settings.py reads, including defaults and which
values are secrets.
Containers should log to stdout. The RotatingFileHandler wrote to
logs/debug.log, which is lost with the pod and crashed startup when
the directory was missing.
All logging now goes to a console handler. LOG_LEVEL sets the root
level (default INFO), DJANGO_LOG_LEVEL sets django.* (default INFO),
and LDAP3_LOG=1 turns on ldap3 protocol logging. ENABLE_DEBUG_LOG and
MODULES_TO_LOG are gone.
nameko-func.py is the 2019 RPC backend that the views replaced with
direct ldap3 calls. Nothing imports it, and the nameko.conf it reads
does not exist.
The v2 rewrite started in December 2021 and never got past a register
view that stored users in Django's database instead of LDAP. Its goals
(ldap3 only, multiple LDAP servers, a current Django) now live in the
dal app, which is what production runs.
EMAIL_HOST, EMAIL_PORT and EMAIL_USE_TLS are deprecated in 6.1 in favour
of the MAILERS setting. The same environment variables now feed the
default SMTP mailer, so existing deployments keep working. Also adds
optional EMAIL_HOST_USER, EMAIL_HOST_PASSWORD and EMAIL_TIMEOUT
(default 10s; previously no timeout).
Step 4 of the upgrade and the final target. No code changes needed;
all dependencies are pure Python now. The remaining deprecations come
from Django 6.1's new MAILERS setting and are handled separately.
LDAPSERVER was documented as a space separated list, but the whole
string went into a single ldap3.Server. Logins worked (python-ldap
accepts lists), while registration and every write would have failed
with more than one URI.
settings.LDAP_SERVERS is now the split list, used through one ldap3
ServerPool per process: requests go to the first reachable server, a
server that is down is skipped for 60s, connections time out after 5s,
and with all servers down the pool gives up after two passes instead
of retrying forever.
python-ldap is a C extension that needs the OpenLDAP headers to build
(and needed a workaround in the Dockerfile). ldap3, which the app
already used for all writes, is pure Python.
dal.backends.LdapBackend keeps the django-auth-ldap behaviour: search
the uid in every LDAPSEARCH base as the admin DN, require exactly one
match, log in with the user's DN, and mirror givenName, sn and mail into
the local Django user. Empty passwords are rejected (LDAP treats them
as anonymous logins, which succeed).
LDAP_USE_TLS now applies StartTLS to every connection, including the
admin connection used for writes, not just logins.
Step 3 of the upgrade (Python 3.12). No code changes needed: the
previous commits removed everything Django 5.x drops, and the tests
run with no Django deprecation warnings.
utcnow() is deprecated since Python 3.12. Calling .timestamp() on its
naive result also read it as local time, so token ages were off by the
host's UTC offset everywhere except on UTC hosts. Unix time from
time.time() is what the code meant.
Remove USE_L10N; localized formatting is always on from 5.0. Replace
User.objects.make_random_password() (removed in 5.1) with a
secrets-based generator for the create API's initial password, using
the same unambiguous alphabet and 16 characters instead of 10.
Step 2 of the upgrade. Dependencies are bumped to their newest versions
compatible with Django 4.2.
Django 4.0 rejects cache_control applied directly to a view method, so
Index.get now wraps it in method_decorator. Without this the landing
page returns 500. A test now checks the no-store header.
Step 1 of the upgrade (Python 3.9). Dependencies are bumped to their
newest versions compatible with Django 3.2.
DEFAULT_AUTO_FIELD is set to AutoField, which matches the existing
tables, so no migration is needed and the W042 warnings go away.
SeedRetrieveCreate printed every submitted password to stdout, so it
ended up in container and server logs. The reset view logged complete
reset URLs at debug level, and those work as credentials until used.
The highest uidNumber was cached in dal/ldap_max_uid_file. That breaks
with more than one replica and is lost on every container restart. The
uniqueness check behind it searched base '' (the root DSE), which
OpenLDAP never matches, so nothing caught a stale file. The error path
also referenced a setting that does not exist.
Now a paged search over ENTIRE_SEARCH_BASE finds the highest uidNumber
on each activation. Numbering is unchanged: the next number is above
both LDAP_DEFAULT_START_UID and every existing uidNumber.
LDAP_MAX_UID_FILE_PATH is no longer used.
Registration only collided inside ou=customers, so a staff username
from ou=users could be registered again as a customer, creating two
accounts with the same uid. Check the whole tree (ENTIRE_SEARCH_BASE)
on the register form, in the create API, and again at activation in
case the name was taken in between.
The activation link carried base64-encoded username, password, names
and email, so plaintext passwords ended up in mail archives, browser
history and proxy logs.
Registration now hashes the password to {SSHA} immediately and stores
it in a new PendingRegistration table (migration 0002). The emailed
link is /activate/<token>/ with a random token only, and activation
writes the stored hash into LDAP. Pending entries expire after 24h,
like reset tokens. This also drops the PseudoUser/token-generator
workaround.
Links in emails sent before deployment stop working; those users must
register again.
ResetRequest.post took the username from a hidden form field and
changed that user's password without checking any token, so anyone
could take over any account with a single POST to /reset/.
Opening a valid reset link now stores the verified username in the
session (valid for one hour, cleared after use). The POST only acts on
that user and ignores the form field. Reset tokens now come from
secrets.token_urlsafe, and malformed links return 404 instead of 500.
The uid was formatted straight into search filters, so a password
reset for user '*' matched and changed the first entry in the tree
(staff accounts included). Escape filter values with
escape_filter_chars and DN values with escape_rdn.
Also fix the username pattern: [a-z|0-9|\-|_] allowed '|'.
Restore root requirements.txt pinned to what production runs
(Django 2.2, last patch 2.2.28) so the upgrade starts from a
reproducible baseline. Restore logs/.keep; the logging config fails
on a fresh clone without the directory.
The tests drive every view over HTTP against the real test LDAP:
login, registration and activation, data and password changes,
password reset, account deletion, the create API and OTP seeds.
Known bugs are recorded as expected failures:
- password reset POST changes any user's password without a token
- LDAP filter injection in reset (user='*' hits the first entry)
- activation link carries the base64-encoded password
- a username from ou=users can be registered again under ou=customers