Repository navigation
Releases: dasomel/ldapium
Release list
v0.3.0
Fixes a data-loss race in multi-provider replication and adds an opt-in detector for it.
No configuration change is required; upgrading from 0.2.1 is an image/chart version bump.
The server image's OpenLDAP is now upstream 2.6.15 plus one carried patch, so the
binaries are modified (NOTICE, docs/legal.md); appVersion stays 2.6.15.
Fixed
- Two providers writing the same entry within milliseconds could leave the earlier-CSN
node with a deleted attribute or missingentryCSN/modifyTimestamp, and it did not
heal (#432, #434). The image now carries an OpenLDAP 2.6.15 syncreplretry_diffpatch
(image/patches/, also in the image at/usr/share/doc/openldap/patches/, label
io.ldapium.openldap.patches; #440). Measured: Password Modify race 46/3000 diverged
unpatched vs 0/3000 patched, plain replace 9/8400 vs 0/12600; full CI including
replication-chaosand the upgrade E2E passed on the patched image.
Added
- Cross-provider divergence detector (#437).
replication.check.divergence.enabled
(defaultfalse) makes the replication check CronJob compare recently changed entries
across providers (windowwindowMinutes, default 45; re-read aftergraceSeconds,
default 5; at mostmaxCandidates, default 500, paged). A real divergence fails the job,
which firesLDAPiumReplicationCheckFailed. It stays useful as a safety net for images
built without the patch. Runbook:docs/ha-profile.md. scripts/test/test-replication-write-race-live.py: an opt-in two-provider race harness
(plain replace and Password Modify), not run in CI.
Tests
- The two-provider unpartitioned M5 race in
machine write measurementswas recorded as
a measurement while #432 was open (#435) and is a check again (diverged == 0) now that
the image carries the fix (#440).
Documentation
- Markdown docs brought up to date with 0.2.1, README feature and CI sections (#433);
the replication write-race change package with the D434 decisions and the patch proof
(docs/changes/replication-write-race/, #436, #439); AGENTS.md anddocs/ha-profile.md
describe the race and the fix.
What's Changed
- docs: bring the markdown docs up to date with 0.2.1 by @dasomel in #433
- test(machine-write): record the two-provider M5 race divergence as a measurement (#432) by @dasomel in #435
- docs(changes): propose the replication write-race mitigation package (#434) by @dasomel in #436
- feat(replication): add two-stage cross-provider divergence detector (#434) by @dasomel in #437
- docs(changes): record the D434-3 retry_diff patch proof (#434) by @dasomel in #439
- fix(image): carry the OpenLDAP syncrepl retry_diff patch (#432, #434) by @dasomel in #440
- chore(release): 0.3.0 by @dasomel in #441
Full Changelog: v0.2.1...v0.3.0
v0.2.1
Maintenance release: dependency and base-image refreshes, one documentation
correction, and more live proof for the machine password operation. No behaviour or
configuration changes; upgrading from 0.2.0 is an image/chart version bump.
Images
- Go toolchain in the image builds:
golang:1.27.2(was 1.27.1) for the exporter
builder in the server image (#424), and the UI backend builder re-pinned to a new
golang:1.27.2-alpinedigest (f92b6ef, #420). - UI backend Go modules:
github.com/go-ldap/ldap/v33.4.15,
github.com/prometheus/client_golang1.25.0 (#421). - UI frontend dependencies (#422): the Radix UI primitives (
react-dialog1.2.0,
react-slot1.4.0,react-tooltip1.3.0 and patch releases of the rest),
lucide-react1.55.0,vite8.3.4,@vitejs/plugin-react6.1.2,oxlint1.87.0,
@playwright/test1.64.0,@types/node26.6.5.
Documentation
- Machine
setPasswordsecond-set response corrected (#417). The 0.2.0 notes and
docs/api.mdsaid an account whose password was already set answers 409. A completed
set clears the pending marker, so a second request is refused 403 by the marker check;
409conflictonly appears for a stuck entry (password present, marker left by a
failed cleanup). The behaviour is unchanged and was measured through a real Keycloak
token and the UI image. - Machine write v2 follow-ups closed (#417). Acceptance wording AC-005/AC-010 now
matches the measured behaviour; maintainer decisions D417-1 (no privileged-group
filter=backstop inwrite-lock.ldif) and D417-2 (nested privileged-group lock
risk stays accepted; list subgroups inMACHINE_WRITE_PRIVILEGED_GROUPS,
docs/machine-write-account.md4.3) are recorded in the change package.
Release pipeline and CI
- The
machine write (real Keycloak)release gate also runssetPasswordend to end
through a real Keycloak token, the production UI image configured by environment
only, and a real slapd, with the plaintext/hash secret scans (#427). - New non-release-critical job
machine write measurements (E-22, two-provider R-3)
repeats the check-to-write window and two-provider replication measurements on
every change (#428, #430). - GitHub Actions bumped (#423):
actions/upload-artifact7.0.2,
actions/download-artifact8.0.2,actions/setup-node7.1.0,
github/codeql-action4.38.3,step-security/harden-runner2.22.1,
ossf/scorecard-action2.5.0,helm/kind-action1.15.1.
Note on the dependency policy
The dependency updates (#420–#424) were merged before the seven-day cooling window in
docs/dependency-policy.md had elapsed, alongside the 0.2.0 release work.
What's Changed
- docs(machine-write): confirm T-027 on the v0.2.0 tag SHA (#379) by @dasomel in #426
- chore(deps): bump golang from
85dc106tof92b6efin /ui by @dependabot[bot] in #420 - chore(deps): bump the actions group with 9 updates by @dependabot[bot] in #423
- chore(deps): bump golang from 1.27.1-bookworm to 1.27.2-bookworm in /image by @dependabot[bot] in #424
- chore(deps): bump the go-dependencies group in /ui/backend with 2 updates by @dependabot[bot] in #421
- chore(deps): bump the frontend-dependencies group in /ui/frontend with 16 updates by @dependabot[bot] in #422
- test(machine-write): live setPassword proof via real Keycloak, UI image and config.Load (#417 b) by @dasomel in #427
- docs+test(machine-write): close the T-060 follow-ups (#417) by @dasomel in #428
- ci(machine-write): run the E-22 and two-provider R-3 measurements (#429) by @dasomel in #430
- chore(release): 0.2.1 by @dasomel in #431
Full Changelog: v0.2.0...v0.2.1
v0.2.0
Breaking / behaviour changes
- Machine
deleteUser/ modrdn narrowed (Q16). With the v2c LDAP rules installed,
machinedeleteUserand modrdn only reach entries created throughW_data; other
entries are refused. - ppolicy state attributes are read-only for self (#400).
pwdReset,
pwdStartTime,pwdEndTimeandpwdAccountLockedTimecan no longer be written by
the user on its own entry. The ACL is bootstrap-only: existing volumes keep their
storedolcAccessuntil upgraded in place (seeimage/README.md, in-place upgrade). - The image refuses to start with
extendedorallinLDAP_ACCESSLOG_OPS(#398).
Usewrites reads bind.
Web UI
- Users and Groups lists page through the cursor API (
limit/cursor/q, #253).
Search is now server-side and matches onlyuid/cn/mail/displayName(users)
andcn/description(groups); the old client-side search overdepartment,
organizationandorganizationalUnitis gone (accepted inD215-7). The list no
longer shows a total count.
Image
- The entrypoint now refuses to start when the accesslog overlay is enabled and
LDAP_ACCESSLOG_OPScontainsextendedorall(#398, F-7,D398-1). OpenLDAP
2.6.15's accesslog overlay aborts slapd (accesslog.c:1623, exit 133) on the first
Password Modify, including the humansetPasswordpath. Usewrites reads bind;
there is no override until upstream fixes it.
API
- Machine write v2c opened (#379, T-026): machine
setPassword. With
MACHINE_WRITE_ENABLEDandMACHINE_WRITE_CRED_ENABLEDon and
directory.users.password.writein a client's ceiling, a machine client sets the
initial password of an account it created itself withcreateUser
credentialPending: true(body exactly{dn, password},Idempotency-Key
required, noIf-Match, response{}; any other target 403, an account whose
password was already set 409). It runs as the separateW_credidentity behind
the T-028 premise checks. OpenAPI marks itmachineBearer/
x-machine-scope: directory.users.password.write. A ceiling may now name the
password scope; withMACHINE_WRITE_CRED_ENABLED=falseit still starts and the
request is 403scope_deniedwithout anyW_credbind (the emergency block;
AC-011 (a) is revised to 403, Q19).
Unlock stays human-only and is the only write scope still refused in a ceiling. - Contract narrowing with v2c on (Q16, accepted 2026-10-14): a deployment that
installs the v2c LDAP rules narrows machinedeleteUser(andW_data's direct
LDAP modrdn; the machine API has no rename operation) to entriesW_datacreated; deleting an administrator-made user (any class) or a
container is refused by the directory (LDAP 50) and answered 403forbidden.
Groups (v2bdeleteGroup) and deployments without v2c are unchanged. W_credgets a no-lockout password policy (CHANGE.md Q17, decided 2026-10-14):
guidedocs/machine-write-account.md8.1 attacheswcred-policy.ldif
(pwdLockout FALSE, nopwdMaxFailure), so wrong binds can no longer lock
W_credand stopsetPassword; the operator owns brute-force mitigation (long
random password, network restriction, failed-bind alert). The premise checks
enforce it (Q18 (b)): at start-up and every 60 sW_cred's policy must exist,
differ from the default and pending policies and statepwdLockout: FALSEwith
nopwdMaxFailure/pwdMaxAge/pwdMustChange TRUE, else start-up is refused /
setPasswordanswers 503.- Accepted risk (2026-10-14): with v2c on, a leaked write credential can rename an
administrator-made user'smail/uidand give the old value to a new pending
account; applications that resolve the login name frommail/uidare at risk
(bind byentryUUIDor alert on changes, or keep v2c off). - Chart:
ui.machineAuth.write.cred.*renders theW_credidentity, the
configuration reader and the canaries (MACHINE_WRITE_CRED_*; passwords are
Secret references only, every identity and Secret key distinct). It still
refuses next toaudit.enabled=trueor an accesslog ops list holding
extended/all. The chart still grants no write scope inallowedClients. - Machine write v2c (#379, T-020) configuration and start-up validation only; no
operation is opened (directory.users.password.writeis still refused in a
ceiling and the password routes stay 403scope_deniedwith no bind). New
MACHINE_WRITE_CRED_ENABLED(defaultfalse; when off no other
MACHINE_WRITE_CRED_*is read). When on, start-up requires
MACHINE_WRITE_CRED_BIND_DN/_PASSWORD(distinct from every other identity, in
the protected set), a non-emptyMACHINE_WRITE_PRIVILEGED_GROUPS,
MACHINE_WRITE_CRED_SUBTREESinside the same client'sMACHINE_WRITE_SUBTREES,
andMACHINE_WRITE_CRED_PENDING_POLICY_DN(not the default policy); optional
MACHINE_WRITE_CRED_MIN_LENGTH(12–1024, default 12) and
MACHINE_WRITE_CRED_POLICY_DNS. The policy entries join the protected set, so a
write ceiling holding one fails start-up. The chart is unchanged. See
machine-write-account.md section 8. - Machine write v2c (#379, T-021) LDAP fragments, opt-in and not yet used by the app:
W_cred
(write-cred.ldif, initial password on pendingW_data-made accounts only, cleanup of the marker
and pending policy values) and theW_datav2c rules (write-data-cred.ldif). Installing them
requiresolcAddContentAcl TRUEon the main database and narrows machinedeleteUser/modrdn to
entriesW_datacreated, groups excepted (Q16). Guidedocs/machine-write-account.mdsection 8; the read-only
checker gains--cred-dn/--pending-policy-dn/--privileged-group/--wcred-unpinned; non-pinned
providers getwrite-cred-deny.ldif. Image defaults unchanged. - Machine write v2c (#379, T-023) password limits and audit, still not opened:
newMACHINE_WRITE_CRED_RATE_LIMIT_RPS(global password budget, 1–10, default 1,
read only withMACHINE_WRITE_CRED_ENABLED=true) and a per-target serialization
(entryUUID, shared across clients); either refusal is429 machine_rate_limited
withRetry-After. Audit lines gaincompartment=cred(no password field). - Machine write v2c (#379, T-025) per-compartment emergency-block drill for the password
compartment, tests and docs only,setPasswordstill closed (T-026 opens it). New
scripts/test/test-machine-write-cred-drill.pyand alive-tagged Go test measure the
MACHINE_WRITE_CRED_ENABLED=falseredeploy, theW_credrotation (old credential refused,
W_data/W_lock/read unaffected on running replicas, restarts fail until the new Secret is
rolled out), theMACHINE_REQUEST_TIMEOUTbound of an in-flight request (8.0 s at 10 s, 2.0 s
at 4 s), theW_credlockout atpwdMaxFailureand the Revision 8 manual cleanup of a stuck
entry, run live.docs/machine-auth-operations.mdgains "Password compartment emergency
block". A flag-only redeploy answers 503, not the 403 of AC-011 (a); AC-011 is corrected. The
W_credlockout policy is an open maintainer decision (CHANGE.md Q17). - Machine write v2c (#379, T-028) run-time premise checks and sweeps, still not
opened (setPasswordstays unreachable until T-026). With
MACHINE_WRITE_CRED_ENABLED=truestart-up now also requires
MACHINE_WRITE_CRED_ACL_READER_DN/_PASSWORD(acn=configreader, distinct from
every other identity, in the protected set) andMACHINE_WRITE_CRED_CANARIES(one
set of three locked canary entries inside everyMACHINE_WRITE_CRED_SUBTREESDN), and
readsMACHINE_WRITE_CRED_PENDING_TTL(default24h, 1h–720h). The UI then checks,
at start-up (failure: non-zero exit), before every password write (failure: that
request 503, no write) and every 60 s (failure: password operations 503 until a
passing cycle): the main databaseolcAccessagainst the guide's layout as text,
olcAddContentAcl TRUE, the default and pending policy values against the floor
(the image's defaultpwdMinLength8 is below the floor 12: v2c deployments need
LDAP_PASSWORD_MIN_LENGTH>=12, an intended operating cost), nocn=accesslogchild
readable byM, and the canary probes. The 60 s cycle also runs the stuck / TTL /
orphan / foreign sweeps of the pending policy (W_cred cleans the first three, with a
re-read retry; a cleanup that still fails closes the password operations and alerts;
after a failed check the sweep only counts). Start-up sweeps read-only and refuses to
start on a stuck entry; a cycle older than two periods closes the password operations.
MACHINE_WRITE_CRED_POLICY_DNShas no effect yet (the pre-read admits only the pending
policy). Only result codes and counts are logged. New
fixturesconfig-reader.ldif,config-reader-main.ldifand guide sections 8.8–8.9
(docs/machine-write-account.md). Chart:
ui.machineAuth.write.cred.enabled(defaultfalse) exists only to refuse: next to
audit.enabled=true(D379-14), next to anaudit.accessLog.opsholdingextended
orall(F-7), and on its own until T-026. - Machine write v2c (#379, T-024) live proof of the password step, tests only, still
not opened:scripts/test/test-machine-write-cred-secret-live.pyruns the real
pipeline (alive-tagged test server that addssetPasswordto its own table) against
slapd and scans responses, headers, the UI log and audit, the slapd log and
cn=accesslog(rootdn andM) for the one-time sentinels and for hash patterns: 0
in the configuration the chart and T-028 accept; withaudit.enabledthe auditlog
carries hashes, which the chart refuses. The T-028 check-5 pre-hashed probe is now
one fixed public constant ({SSHA}ofzz, saltldapium!; it was a random throwaway
value): its refused Modify still leaves that value incn=accesslogat start-up and
...
v0.1.1
Maintenance release: dependency and base-image refreshes, plus the release
pipeline fix that let 0.1.0's supply-chain evidence run. No behaviour or configuration
changes; upgrading from 0.1.0 is an image/chart version bump.
Images
- Base images re-pinned to new digests:
debian:trixie-slim(a99cfc5, builder
and runtime) andgolang:1.27.1-bookworm(69a7b97, exporter builder). Both
still coverlinux/amd64andlinux/arm64. - UI frontend dependencies:
lucide-react1.47.0,react-router/
react-router-dom7.18.4,@types/node26.6.2,oxlint1.83.0.
Release pipeline and CI
- The supply-chain licence gate installs Go/Node dependencies before running,
so it no longer regenerates an empty inventory and fails (#191). - GitHub Actions bumped:
docker/setup-buildx-action4.4.1,
docker/build-push-action7.4.0 (includes an upstream fix for workflow
command injection through metadata logs),github/codeql-action4.38.1. - Workflow concurrency groups no longer cancel or get displaced on
main
push: a late-starting run for an older commit had cancelled HEAD's
in-progress E2E after four PRs merged back-to-back (2026-09-24), leaving
HEAD with no completed E2E.main/schedule/dispatch runs are now grouped
per commit SHA instead of per ref, so they never share a concurrency group
with another commit's run;cancel-in-progressstays scoped to
pull_requestonly. Docs-only changes (**.md,docs/**,research/**)
also no longer trigger the non-required E2E suites on push tomain
(metrics, replication chaos, security, SSSD, UI);ci.yml,e2e.yml,
backup-restore.yml, andupgrade-e2e.ymlstill run on every push to
main, since branch protection orrelease.yml's release-critical checks
require their results on every commit that could be tagged.
Note on the dependency policy
These updates were merged before the seven-day cooling window in
docs/dependency-policy.md had elapsed, at the maintainer's request, after a
review of each upstream release (see #192–#195).
What's Changed
- fix(ci): install Go/Node deps before the supply-chain licence gate by @dasomel in #191
- docs(research): record 2026-09 verification evidence by @dasomel in #196
- build(deps): bump debian from
d7e1218toa99cfc5in /image by @dependabot[bot] in #193 - build(deps): bump the actions group with 5 updates by @dependabot[bot] in #195
- build(deps): bump the frontend-dependencies group in /ui/frontend with 4 updates by @dependabot[bot] in #194
- build(deps): bump golang from
648f440to69a7b97in /image by @dependabot[bot] in #192 - chore(release): prepare v0.1.1 by @dasomel in #197
Full Changelog: v0.1.0...v0.1.1
v0.1.0
First release, and a prototype: everything below was verified against
something running, but nothing has been run outside its author's environment.
helm test <release> is shipped with the chart so an install can be checked
rather than assumed.
Server image (ghcr.io/dasomel/ldapium)
- OpenLDAP 2.6.14 compiled from the upstream tarball, pinned by version and
sha256, ondebian:trixie-slim.linux/amd64andlinux/arm64, each built
on a native runner — OpenLDAP'sconfigureuses runtime probes that are not
reliable under emulation. slapdruns as PID 1 as uid 999, configured entirely throughcn=config.- No sample data and no default admin password. The container refuses to start
without one; seeding is opt-in through a mounted LDIF directory. - Overlays enabled by default:
memberof,refint,ppolicy,unique, and
syncprovwhen replication is on. Also built and loadable:accesslog,
auditlog,constraint,deref,dynlist,sssvlv,otp. {ARGON2}password hashing, loaded as a module.- N-way multi-provider replication, including a cold-start election so several
nodes booting at once cannot each create their own copy of the base DIT. - Tuned for large directories: paged results, configurable size/time limits,
and an explicit file-descriptor limit (slapd reserves memory per descriptor,
so an inherited 1M-descriptor limit alone cost ~650MB of RSS).
Management UI (ghcr.io/dasomel/ldapium-ui)
- Go backend, React frontend, shipped as one distroless static image running as
uid 65532. - DIT browser, user and group CRUD, group membership by search-and-select,
password set and self-service change, account unlock,memberOfdisplay,
paged listings, and a password-policy view. - Every request binds as the logged-in user, so the directory's own ACLs decide
what a session can do. The UI holds no service account. - Optional Keycloak SSO (OIDC with PKCE), gated on a realm role. The login
state is bound to the browser that began it, so a state and code obtained
elsewhere cannot be used to log somebody else's browser into the attacker's
account. This is the one path that uses a dedicated LDAP service account,
because a token carries no password to bind with. - Korean and English throughout, and inline explanations of LDAP terminology
for people who do not work with directories daily. - Backup status is read from the directory itself, so the UI needs no
Kubernetes access to show it.
Helm chart (oci://ghcr.io/dasomel/charts/ldapium)
- StatefulSet with per-replica PVCs, headless Service, PDB, and topology
spread.replicaCount: 1runs standalone; above that, replication turns on
and the peer list is wired automatically. - Refuses to render without an admin password.
- Optional UI Deployment, and a backup CronJob that dumps the data tree and
cn=config, prunes by retention, and records each run intoou=operations.
Also in this repo
docker-compose.ymlfor a non-Kubernetes deployment, andscripts/backup.sh
covering the same ground as the CronJob outside Kubernetes.scripts/get-credentials.shfor retrieving admin credentials from either a
Compose or a Kubernetes deployment.
Known limitations
- TLS is unverified end to end. The templates render and the entrypoint
handles certificates and TLS-protected replication, but no one has watched it
work against a live cluster. It is off by default. Reports welcome. - The settings page lists modules and overlays from configuration, not from
the server. Those values live incn=config, which has its own admin
identity that no UI session can hold, so the list is kept in step with
image/Dockerfileby hand. - No upgrade path is promised yet. 0.1.0 is the first release; there is
nothing to upgrade from. - The e2e workflow has never run. It installs the chart into a kind
cluster and runshelm test; the test script itself was developed against a
live directory and its failure modes verified, but the CI wiring around it
is new.
What's Changed
- fix(ci): unbreak server image build and shellcheck lint by @dasomel in #37
- test(tls): cover certificate expiry and rotation, and document the renewal runbook by @dasomel in #38
- fix(backup): make scheduled backups verifiable, and recover a 3-node directory in CI by @dasomel in #39
- test(upgrade): cross an actual version, measure the cost, and gate on a backup by @dasomel in #40
- test(replication): partition the cluster, and kill the node that elects itself by @dasomel in #46
- feat(supply-chain): make the air-gap path testable, and test it by @dasomel in #47
- test(metrics): inject the faults and require the alerts to fire by @dasomel in #48
- test(security): deny an authenticated user another user's entry, and stop the module inventory drifting by @dasomel in #50
- fix(supply-chain): stop two build tools floating on latest, and verify the module graph by @dasomel in #51
- fix(supply-chain): pin every base image by digest by @dasomel in #52
- feat(audit): emit an attributable record for every write by @dasomel in #53
- fix(chart): stop helm test from asking twice for one pod's logs by @dasomel in #55
- test(ci): make the "make check runs what CI runs" claim checkable by @dasomel in #54
- fix(release): require the tagged commit to have been tested by @dasomel in #56
- feat(supply-chain): record artifact digests and OpenLDAP provenance by @dasomel in #57
- fix(supply-chain): stop resolving build tools against @latest by @dasomel in #58
- feat(supply-chain): audit build/test egress on the dependency-resolution jobs by @dasomel in #59
- docs(security): draw the encryption-at-rest boundary, fix backup transport by @dasomel in #60
- docs(interop): draw the client compatibility boundary by @dasomel in #61
- feat(audit): instantiate accesslog for reads, unify export with auditlog by @dasomel in #62
- feat(scale): local reproduction benchmarks for load, search, replication by @dasomel in #63
- fix(supply-chain): close the remaining #49 gaps — rollback, offline cache, egress test by @dasomel in #64
- fix(ci): actually mint the GitHub attestations docs already promise by @dasomel in #65
- feat(ui): add a cn=Monitor health view (#45) by @dasomel in #66
- test(ui): add this repo's first browser E2E (#45) by @dasomel in #67
- feat(ui): add organizational metadata to users (#14) by @dasomel in #68
- feat(ui): add admin lock (disable) for user accounts by @dasomel in #69
- fix(security): redact internal error detail from HTTP responses by @dasomel in #70
- feat(audit): capture bind events — including failures — in accesslog by @dasomel in #71
- feat(security): enforce a TLS 1.2 floor via olcTLSProtocolMin by @dasomel in #72
- feat(ui): add an unauthenticated LDAP provider health endpoint by @dasomel in #73
- fix(security): redact userPassword from the DIT entry browser by @dasomel in #74
- test(chaos): add a same-entry conflict scenario to replication chaos by @dasomel in #79
- docs: add repo-wide CLAUDE.md, fix stale ui/README.md testing note by @dasomel in #80
- feat(audit): export raw replication CSN discards + enable sync logging by @dasomel in #81
- test(ldap): add LDAP injection fuzz coverage by @dasomel in #82
- test(security): verify LDAP ACL deny-by-default boundaries by @dasomel in #83
- feat(image): add opt-in mTLS SASL EXTERNAL mapping by @dasomel in #84
- test(tls): prove the two-step CA rotation procedure live by @dasomel in #85
- feat(ops): add cn=config drift detection script by @dasomel in #86
- feat(security): enforce an explicit olcTLSCipherSuite baseline by @dasomel in #87
- fix(ops): make detect-config-drift --check compare like-for-like by @dasomel in #88
- refactor(ci): share the LDAPS availability probe between both rotation tests by @dasomel in #89
- test(security): prove rootdn and user writes are distinct actors in the audit export by @dasomel in #90
- feat(ui): throttle failed password logins per client IP by @dasomel in #91
- test(session): make fakeClient.closed atomic so -race passes by @dasomel in #92
- feat(image): opt-in LDAP_ANONYMOUS_READ_...