alder

module
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: AGPL-3.0

README

Alder

A modern web UI for OpenLDAP and 389 Directory Server. Browse the schema, edit entries safely, export every change as LDIF or Ansible.

A directory engineering tool whose output is code.

Alder is for the platform engineer who owns a directory and currently manages it with ldapsearch, hand-written LDIF, and a twenty-year-old PHP tool they do not trust. Everything Alder does to a directory can be previewed as LDIF and exported as an Ansible task. It is not a click-here-and-hope admin panel; it is an authoring environment for directory changes, with a browser attached.

Alder wood hardens instead of rotting when submerged. Venice stands on alder piles that have held for centuries. It seemed a fitting name for the directory everything else authenticates against.

Status

Feature complete for v1. Connect, browse, search, edit, import and export all work, against both target servers.

M0 Repo, test harness, dn and filter packages, CI. Done.
M1 Driver interface, LDAP driver, RootDSE capabilities, RFC 4512 schema parser, conformance suite. Done.
M2 React UI: connect, browse the tree, view entries, browse the schema. Done.
M3 Write path: ChangeRecord, LDIF preview with a mandatory confirm, schema-driven editor. Done.
M4 LDIF import and export, Ansible export. Done.
M5 Release: screenshots, GoReleaser, GHCR, security policy, automated versioning. Done.

docs/DECISIONS.md records the decisions behind the design and why the alternatives were rejected. CONTRIBUTING.md has the scope boundaries and the rules that are not style preferences.

The entry viewer

An entry from the test harness, chosen because every one of its values is awkward: a leading space, a trailing space, one starting with a colon, one with an embedded newline, one with non-ASCII bytes. Required attributes are marked, the directory's own operational attributes are filed separately and locked, and timestamps are rendered as dates.

Try it

task compose:up                        # OpenLDAP and 389 DS, seeded, with TLS
task build                             # SPA + binary
./bin/alder serve --addr 127.0.0.1:8443 --allow-http

Then open the address it prints and connect to localhost:10636 (OpenLDAP, cn=admin,dc=alder,dc=test / alder-admin) or localhost:11636 (389 DS, cn=Directory Manager / alder-directory-manager), pasting test/compose/certs/ca.crt into the CA field.

Or with Docker:

docker build -t alder .
docker run --rm -p 8443:8443 alder serve --tls-cert /certs/tls.crt --tls-key /certs/tls.key

Alder refuses to start without TLS. It holds a directory bind password in memory for the life of a browser session and will not carry it over a plaintext connection by default; pass --allow-http if something in front of it is terminating TLS.

What it does

Browse the DIT as a lazy tree, one level per expansion. Entries render with their attributes annotated from the schema: required attributes marked, the directory's own operational attributes filed separately and read-only, DN values as links, timestamps as dates, binary values as sizes rather than mojibake.

Edit through a form the schema builds. Single-valued attributes get no "add value" button. NO-USER-MODIFICATION attributes are not editable. "Add an attribute" offers exactly what the entry's object classes permit, and nothing else. An attribute whose values are binary is shown but not editable as text.

Confirm every write. There is one code path that modifies a directory and it opens this dialog first, showing the exact RFC 2849 change record that will be sent — rendered by the server from the same ChangeRecord it will act on, so the preview and the effect cannot drift apart. The same record renders as a community.general Ansible task on the next tab.

The LDIF preview

If someone else changes the entry while you have it open, the editor says which attributes drifted and that applying will overwrite them, rather than silently discarding your work or silently clobbering theirs.

Search with a filter builder or a raw RFC 4515 filter. Filters are parsed, never interpolated. Every search is paged and bounded, and a truncated result says so.

Every search hands back the ldapsearch that runs it, for a runbook, a ticket or a script. The server renders it, so the filter in it is the one the server parsed and sent rather than the text in the box — normalising it is the whole point of parsing it, and a command built from what was typed would describe a different search from the results beside it. It reproduces what the session actually did, down to -ZZ for StartTLS and an LDAPTLS_REQCERT=never where verification was skipped. The password is not in it: -W prompts.

Searching for people and groups is most of what anyone does, so those are ready made: tabs that run the filter for you and show the columns that matter for what you asked about. The columns are yours to choose, from what the schema says the matching entries can actually hold rather than from a fixed list — a directory's own attributes are the interesting ones, and a fixed list never has them. Choosing a column re-runs the search asking for it, because a column added to rows already fetched shows a dash that reads as "there is no value" when it means "nobody asked for one".

See what names an entry before you break it. Which groups is this account in, what else points at it — the question asked most often about an account, and the one to ask before deleting anything. The answer says which attribute each referring entry uses, not merely that it refers, because they are not the same thing to undo: one entry can hold the same person as a member and as an owner, and removing either is a delete of one value of that one attribute on the entry doing the naming. So each is offered separately, and each goes through the usual preview.

The values are compared as the directory would compare them. A server may return a reference spelled differently from the entry's own DN, and uniqueMember carries an optional UID suffix that is not part of the DN at all; a reference missed for either reason is a reference nobody is warned about.

Browse the schema — object classes, attribute types, syntaxes and matching rules, fully cross-linked. A class shows what it requires and permits, split between its own and its inherited attributes, and every one is a link. An attribute shows the classes that require and permit it, its syntax, and the definition exactly as the server published it. Definitions the parser could not read are listed rather than hidden.

The schema browser

Stage a changeset. Every confirmation dialog can queue its change instead of applying it. A staged set is read as one LDIF document and exported as one Ansible playbook, reordered by hand, and applied in order. Alder points out what no single change can see — an entry created before its parent, an entry acted on after being deleted, the same entry changed twice — and reorders nothing on its own, because a move under something created later is a legitimate thing to want.

A run stops at the first refusal and reports what landed and what was not attempted; LDAP has no transaction across entries, so nothing is rolled back and Alder does not pretend otherwise. What did not apply stays staged, in order, so correcting one change and applying again resumes rather than repeats.

Browse the server's own configuration. A directory keeps its configuration in the directory, and that tree appears in the browser beside the data whenever the session can read it — the databases, the access rules, the schema. The DN is taken from what the server announces, and otherwise found by trying the conventional location and believing only what the server answers.

The account that administers a suffix usually has no rights in the configuration, so the connection screen takes an optional second identity for it. Requests are routed by DN: one session browses people as the directory administrator and the configuration as the configuration administrator, and neither borrows the other's rights. Without it, reaching the configuration would mean connecting as the configuration administrator and giving up the data.

Edit the configuration, carefully. Configuration entries are ordinary entries, so the same editor and the same LDIF preview work on them. Changing them is not ordinary, so a change addressed into the configuration tree says so, and a change touching something you can lock yourself out with — access rules, the administrative identity, the ports the server listens on — says which and why. Nothing blocks; the directory decides, and you are the one who asked.

Where a server keeps its schema in the subschema subentry rather than in configuration entries, that entry is a root of its own. It sits outside every naming context, so it appears under nothing else — which made it the one part of such a directory the tree could not reach.

Edit the schema. Object classes and attribute types can be added, changed and removed, through a form or by writing an RFC 4512 definition out by hand — either way it is parsed and checked before anything is sent.

A schema definition is a value of an attribute on an ordinary entry, so a schema change is an ordinary modify, and it goes through the same confirmation, the same LDIF preview, the same Ansible export and the same changeset as every other write. There is still exactly one code path that writes to a directory.

Where the schema is kept differs, and Alder reads that from the server rather than from its name. A server that announces a configuration tree generates its subschema subentry from configuration entries, so a definition is written to one of those — and Alder asks which, because they load in order and the first is the server's core schema. A server that announces none has a subschema subentry that is the schema, and takes the change directly.

The value a change removes is read back from the entry that holds it, never from what the schema browser displays. The two differ: a server keeping its schema in configuration prefixes each stored definition with its load order and strips that prefix from what it publishes, and 389 DS records X-ORIGIN 'user defined' on anything added at runtime. A change built from the displayed form would match nothing on a removal and leave two definitions of one OID on an edit.

Import and export. Export an entry, a subtree, or what a search is showing — the table's filter goes to the exporter, because "the thirty people I just searched for" is the thing that actually goes in a ticket, and it used to be assembled by hand. The entries come out whole rather than cut down to the displayed columns: a partial entry is not something you can put back. A filtered export says so in the file's header, since a file that does not describes something narrower than its base and reads later as the whole of it.

The same thing exports as an Ansible playbook that enforces it rather than one that merely creates what is missing. ldap_entry with state: present asserts an entry exists and stops there — run it against an entry holding entirely different attributes and it reports success and changes nothing — so each entry gets a create task and an ldap_attrs task with state: exact, ordered parent first because nothing can create a child under a parent that does not exist yet. Attributes the directory owns are left out, since a task enforcing one fails on every run, and sensitive ones are left out with no way to include them: a playbook is a file destined for a repository.

Import a document and apply its records one at a time, each through the same confirmation, or stage the whole parsed document into the changeset and review it as one thing. A file of forty records is a decision about forty records, and confirming them one by one is not the same review.

Delete a container by staging what is under it. LDAP deletes one leaf at a time, so removing an organisational unit means removing everything below it first, deepest first, in order. Alder walks the subtree, stages the deletions in that order, and hands you the list to review as one changeset — rather than refusing because the entry has children, which is what it used to do while offering no way to do the thing it was suggesting.

Two servers, one behaviour

OpenLDAP and 389 Directory Server are both first-class, and that is enforced by one table-driven conformance suite that runs every case against both:

task test:conformance:up

Nothing in Alder branches on the vendor. The RootDSE is read once at connect time into a Capabilities value and behaviour follows from that. It is why the same code finds the schema at cn=Subschema on OpenLDAP and cn=schema on 389 DS without knowing which is which.

Security posture

  • Bind credentials live in server memory keyed by an httpOnly, Secure, SameSite=Strict cookie. Not on disk, not in a JWT, not in localStorage. Restarting the server logs everyone out.
  • Sensitive attributes (userPassword and friends) are never sent to the browser and never logged. The UI reports them as set, with a count.
  • LDIF exports omit them too, unless explicitly asked for.
  • attr:< url references in imported LDIF are refused. Following one from a process holding a privileged bind would be file disclosure via file:// and request forgery via http://.
  • Filters are parsed and re-escaped; DNs are parsed and re-rendered. Neither is ever built by string concatenation.
  • TLS is on by default in both directions.

Working on it

task                 # every task
task check           # vet, lint, test: what CI runs
task compose:up       # two directory servers, seeded, with TLS
task test:conformance
task generate        # regenerate the API types after editing api/openapi.yaml
task dev             # prints the two commands for hot-reloading the SPA

You need Go 1.25 or newer, Node 22 or newer, and Docker. See test/compose/README.md for what the harness gives you.

api/openapi.yaml is the source of truth for the HTTP API. The Go server interface and the TypeScript client are both generated from it.

Layout

Package
internal/dn RFC 4514 distinguished names. DNs are never strings; there is no exported way to build one by concatenating text.
internal/filter RFC 4515 search filters, built and parsed. Values are escaped and a raw filter typed by a user is parsed rather than passed through.
internal/schema RFC 4512 schema: parser, index, and the presentation opinion that drives the editor.
internal/ldif RFC 2849 reader and writer. Values are []byte throughout.
internal/directory The Driver and Session interfaces, Capabilities, and ChangeRecord.
internal/directory/ldapdriver The only driver in v1.
internal/ansible ChangeRecord to a community.general task.
internal/api The generated server interface and the handlers behind it.
internal/session In-memory session store.
internal/web The embedded SPA.
web/ The React application.
test/compose OpenLDAP and 389 DS, TLS from one CA, 320 identical entries each.
test/conformance One suite, both servers, identical assertions.

Licence

GNU Affero General Public License v3.0.

The AGPL is deliberate rather than incidental. Alder is a tool you run as a service, and section 13 is the clause that matters: if you modify Alder and let other people use it over a network, you have to offer them your modified source. That keeps the conformance harness — the expensive part, and the thing that makes "works on both servers" true rather than aspirational — from being absorbed into something closed.

Using Alder to administer your directory imposes nothing on you. Your directory's data is yours, the LDIF and Ansible it generates are yours, and running it internally is not distribution. The obligation attaches only if you modify Alder itself and offer that modified version to others.

Alder serves its own source offer at /api/v1/source, which is what section 13 asks for. If you deploy a modified build, point --source-url at your fork.

Contributions are covered by a Contributor Licence Agreement, which keeps the option of licensing Alder on other terms open. Contributors keep the copyright in their work.

Directories

Path Synopsis
cmd
alder command
Command alder is the Alder directory engineering tool.
Command alder is the Alder directory engineering tool.
internal
ansible
Package ansible renders a directory.ChangeRecord as an Ansible task.
Package ansible renders a directory.ChangeRecord as an Ansible task.
api
Package api provides primitives to interact with the openapi HTTP API.
Package api provides primitives to interact with the openapi HTTP API.
directory
Package directory defines the interface Alder talks to a directory through, and the ChangeRecord every modification is expressed as.
Package directory defines the interface Alder talks to a directory through, and the ChangeRecord every modification is expressed as.
directory/ldapdriver
Package ldapdriver implements directory.Driver over the LDAP protocol.
Package ldapdriver implements directory.Driver over the LDAP protocol.
dn
Package dn implements RFC 4514 distinguished names.
Package dn implements RFC 4514 distinguished names.
filter
Package filter builds and parses RFC 4515 search filters.
Package filter builds and parses RFC 4515 search filters.
ldif
Package ldif implements RFC 2849 LDIF: reading and writing both content records and change records.
Package ldif implements RFC 2849 LDIF: reading and writing both content records and change records.
schema
Package schema implements RFC 4512 directory schema: parsing the subschema subentry a server publishes, indexing it, and answering the questions an editor needs to ask about an entry.
Package schema implements RFC 4512 directory schema: parsing the subschema subentry a server publishes, indexing it, and answering the questions an editor needs to ask about an entry.
session
Package session holds directory connections in memory, keyed by a cookie.
Package session holds directory connections in memory, keyed by a cookie.
web
Package web embeds the built single-page application and serves it.
Package web embeds the built single-page application and serves it.
test
compose/seed/gen command
Command gen writes the generated part of the conformance seed data.
Command gen writes the generated part of the conformance seed data.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL