--- layout: default title: LDAP description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly. --- # LDAP Directory [← Back to Home](index.html) > Looking for a plainer explanation of accounts, groups, and managers > instead of schema/attribute detail? See > [Accounts, Groups & Managers](concepts-accounts.html). SSO Manager runs an OpenLDAP directory holding your users and groups. The app authenticates against it over `localhost:389` (inside the all-in-one container) and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the theta42/proxy, etc. ## Directory layout ``` dc=yourdomain,dc=com ├── ou=people users (inetOrgPerson + posixAccount + …) ├── ou=groups groups (groupOfNames) └── ou=policies password policies (pwdPolicy) └── cn=ppolicy default policy ``` ### Users User entries are `cn=,ou=people,` and carry the objectClasses: - `inetOrgPerson` (cn, sn, mail, …) — identity / contact attrs. - `posixAccount` (uid, uidNumber, gidNumber, homeDirectory) — the SSO's `userFilter` is `(objectClass=posixAccount)`, so a user is "a real account" iff it has `posixAccount`. - `ldapPublicKey` — SSH public keys (`sshPublicKey`). - `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`). - `theta42Person` (custom auxiliary; `dateOfBirth`). Every user (person or service account) also carries a `manager` attribute (the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of the people who created/administer that account. Set automatically to the creator's DN on signup (whoever an admin was logged in as, or whoever sent the invite), and reassignable later from the account's Edit form. Anyone listed as a `manager` can edit that account (same fields an admin can: mobile, description, SSH key, date of birth, home directory, login shell, and the manager list itself) without needing `app_sso_admin`. Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64), verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the canonical hasher; if you provision users out-of-band, hash passwords the same way or use `slappasswd -h '{SSHA512}'`. ### Groups Groups are `cn=,ou=groups,` (`groupOfNames`) with a `member` attribute listing member DNs. The `memberOf` overlay populates reverse membership (`memberOf` on the user); `refint` keeps it consistent on add/remove. Note that `groupOfNames` requires **at least one member**, which has two consequences worth knowing: whoever creates a group is automatically seeded into it, and removing the last member (user *or* nested group) is refused with a 409 rather than leaving an invalid entry behind. ### Nested groups A `member` DN may be another group's, not just a user's — that is how nesting is stored, with no extra schema. Everyone in the nested group is a member of the outer one, at any depth. Manage it on the **Groups** page under each group's *Nested* tab, or via the API: ``` PUT /api/group/:group/nested/:child nest :child inside :group DELETE /api/group/:group/nested/:child un-nest GET /api/group/:group/effective direct users, nested groups, and the full transitive set of users ``` Cycles are refused (409) rather than truncated — a loop makes "who is in this group" unanswerable. Two standing relationships are wired automatically: the cross-app `app_super_admin` is nested into every resource's `_admin` group, and each `_admin` into its `_access` group, so administering something implies being able to use it. **Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release can evaluate nested groups; `memberOf` and a `(member=X)` filter both return direct membership only. The bundled slapd is therefore built from source with the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so via `ldap.nestedGroupsServerSide`. Against any other server the app computes the closure itself — same answers, more queries. Either way, **never read `memberOf` directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`, which is correct in both modes. ### Personal groups Every user (person or service account) also gets a **personal Unix group** at creation — `cn=,ou=groups,`, `objectClass: posixGroup` (RFC 2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a different schema than the `groupOfNames` groups above — its membership attribute is `memberUid` (a bare username, not a DN), and unlike `groupOfNames` it's valid with zero members. It's excluded from the `/groups` page (which filters on `objectClass=groupOfNames`) and managed instead from the owning user's own profile page ("Members of ``'s group", admin-only) — add other accounts as supplementary members, e.g. to share write access to files owned by this group. The SSO seeds these groups automatically (entrypoint / `install.sh`): | Group | Grants | |-------|--------| | `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. | | `app_sso_admin` | full admin (users, groups, settings) | | `app_sso_oauth_admin` | OAuth client management | | `app_sso_invite` | invitation management | | `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. | ## TLS (LDAPS / StartTLS) The bundled slapd generates a **self-signed cert** on first start (CN = `LDAP_CERT_CN`, valid 10y, SAN = CN + `localhost` + `127.0.0.1`) and listens on: - `ldaps:///` — **636**, TLS (the port to expose for direct-LDAP clients). - `ldap:///` — **389**, plain + StartTLS (not mapped to the host by default). The cert lives on the `ldap-certs` volume so it persists across container recreation. ### Trusting the self-signed cert Copy it out and add it to the client's CA store: ```bash docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt ``` …or, for quick LAN use, set `TLS_REQCERT never` on the client (the theta42/proxy sets `app_ldap__tlsOptions__rejectUnauthorized=false` for the same effect). ### Using your own cert Replace the `ldap-certs` named volume with a bind mount containing your own `ldap.crt` + `ldap.key`: ```yaml volumes: - ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key ``` The entrypoint leaves existing certs untouched (idempotent). ## Choosing the LDAPS hostname The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By default it derives that URL from the public OAuth issuer (e.g. `https://sso.example.com` → `ldaps://sso.example.com:636`). That is convenient, but it implies LDAP clients reach your directory through the same public hostname — which usually means port-forwarding 636 through your router. **Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds have no rate limiting and are a brute-force target. Instead, use one of these internal-only patterns and set `conf.ldap.ldapsHost` (or `app_ldap__ldapsHost`) so the `/integrations` page shows the right URL. ### 1. Same Docker / local network host (best for apps on this machine) If the LDAP client runs on the same Docker network as the SSO Manager (for example, the bundled `theta-env` stack), use the internal service name: ``` ldaps://sso-manager:636 ``` In `conf/secrets.js`: ```javascript ldap: { ldapsHost: 'sso-manager', ldapsPort: 636, } ``` The proxy in theta-env already uses this internally. The bundled slapd cert includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default, so hostname verification works without extra setup. ### 2. LAN host behind your router (best for separate home-lan machines) Create an internal-only DNS record — e.g. `ldap.internal.example.com` → `192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get or generate a cert whose SAN/CN matches that internal name: - **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the public domain and can complete DNS-01 challenge; the record itself can stay private/routable only inside your LAN. - **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for `ldap.internal.example.com`, and distribute the CA cert to clients. - **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy the generated `ldap.crt` to each client and trust it. In `conf/secrets.js`: ```javascript ldap: { ldapsHost: 'ldap.internal.example.com', ldapsPort: 636, } ``` The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`. ### 3. Public hostname (acceptable only behind a VPN/firewall) If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard, etc.) or a tightly locked-down firewall rule. In that case the public hostname may be appropriate, but the LDAPS port should still not be reachable from the open internet. ### Why not just use the LDAP server's IP address? TLS clients verify the server name against the certificate. Connecting to `ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com` will fail hostname verification unless you disable cert checks — which removes most of the security benefit of LDAPS. Always use a hostname that matches the cert. ## Service accounts A service account is a normal `posixAccount` for something that isn't a person: a media manager, a torrent client, a service like Emby, or a read-only bind account an app uses to look users up — anything that needs a real `uidNumber`/`gidNumber` to own files, or that other accounts join via a group for write access (e.g. a `stuff_manager` group granting write rights to a media library). There's only one kind — every account, person or service, is a real `posixAccount` with a UID. Create one from the **Users → Service Accounts** tab's "Add new user" form with **This is a service account** checked — it skips the birthday/ Terms-of-Service fields a real person's account needs and asks for just an account name. It's flagged (via membership in the `app_sso_service_account` group) so it's listed separately from real people and excluded from "all users" notification broadcasts. Email and password are both optional for a service account: - No `mail` is set unless you give it one (it never needs a mailbox). - Leaving the password blank is fine — no `userPassword` attribute is set at all, and an entry with no `userPassword` simply can't bind with any password (standard LDAP simple-bind behavior). Only set a password if the account actually needs to authenticate as itself (e.g. a bind-only account an app uses to look users up). theta-env's bootstrap creates its own `cn=ldapclient` bind account directly against LDAP (independent of this app), and the proxy binds as it — that account won't show up in the Service Accounts tab since it isn't managed through this app, but it keeps working unchanged. Either way: don't reuse the admin DN, and give a service account only the group memberships and `manager`s it actually needs. Example bind test (a service account with a password set): ```bash ldapsearch -x -H ldaps://sso.example.com:636 \ -D "cn=ldapclient,ou=people,dc=yourdomain,dc=com" -W \ -b "ou=people,dc=yourdomain,dc=com" '(objectClass=posixAccount)' cn mail ``` ## Connecting a 3rd-party app or container Most self-hosted apps with an "LDAP authentication" settings page — Gitea, Nextcloud, Grafana, Emby, Jenkins, etc. — or containers configured via `LDAP_*` env vars, all ask for the same handful of values. These are the `conf.ldap` values from [Configuration](configuration.html), applied to *your* domain: | Field the app asks for | Value | |---|---| | Host / URL | `ldaps://:636` (preferred), or `ldap://:389` + StartTLS | | Bind DN | a dedicated service account — e.g. `cn=ldapclient,ou=people,` (see above) | | Bind password | that service account's password | | User search base | `ou=people,` | | User search filter | `(objectClass=posixAccount)` | | Username attribute | `uid` | | Email attribute | `mail` | | Group search base | `ou=groups,` | | Group membership attribute | `memberOf` (on the user entry — populated by the `memberof` overlay) | | TLS | required for 636 (LDAPS); if using the bundled self-signed cert, either trust it (see *TLS* above) or set the app's "don't verify cert" option for LAN-only use | ### Worked example: Gitea Gitea's **Admin → Authentication Sources → Add Authentication Source** (type LDAP, "Bind DN/Password") maps directly: - Security Protocol: `LDAPS` - Host / Port: your SSO host / `636` - Bind DN: `cn=ldapclient,ou=people,dc=yourdomain,dc=com` - Bind Password: the service account's password - User Search Base: `ou=people,dc=yourdomain,dc=com` - User Filter: `(&(objectClass=posixAccount)(uid=%s))` - Username Attribute: `uid` - E-mail Attribute: `mail` Other apps with an LDAP settings UI follow the same shape — the field names above are the constants; only the base DN and hostname change per deployment. ### Generic Docker container (`LDAP_*` env vars) For images that take a flat env-var LDAP config (there's no single standard, but most look like this): ```yaml environment: LDAP_URL: ldaps://sso.example.com:636 LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com LDAP_BIND_PASSWORD: LDAP_USER_BASE: ou=people,dc=yourdomain,dc=com LDAP_USER_FILTER: (objectClass=posixAccount) LDAP_GROUP_BASE: ou=groups,dc=yourdomain,dc=com ``` Check the specific image's docs for its actual variable names — the values you plug in are still the ones from the table above. ### Full Linux host auth (SSH, sudo, login) instead of a single app If you want a *host* (not just one app) to authenticate logins, SSH keys, and sudo against this LDAP directory — not just one application — that's a different integration (SSSD + PAM + NSS, not a single bind). See [theta42/ldap-client](https://github.com/theta42/ldap-client): a script that configures SSSD on Ubuntu/Debian hosts against this directory, including group-based access control and SSH public key retrieval from LDAP. ## Modules + overlays (external LDAP servers) If you point the app at your own LDAP server instead of the bundled slapd, it needs: - **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`), `ppolicy`, `memberof`, `refint`. - **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still unreleased), so the bundled image builds slapd from a pinned upstream commit. Without it the app resolves nesting itself and everything still works — leave `ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and configure: ``` overlay nestgroup nestgroup-base ou=groups, nestgroup-flags member-filter memberof-filter memberof-values ``` Flags are **space-separated**; the comma form the man page's `{a, b, c}` notation suggests is rejected. `member-values` is deliberately omitted — it expands the `member` attribute when reading a group, which destroys the distinction between "listed here" and "reachable through a nested group", and the raw values are then unrecoverable. Transitive answers come from the filter flags and from `GET /api/group/:group/effective`. One more consequence of building from master: it ships **LMDB 1.0.0**, whose on-disk format is mutually unreadable with the 0.9.x in 2.6.x (`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two is a `slapcat` → `slapadd` reload, not a restart. - **Custom schema:** the `theta42Person` auxiliary objectClass with `dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF. - **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN, a default `pwdPolicy` at `cn=ppolicy,ou=policies,`. - **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`, and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint also nests it into the first three). `ops/ldap-setup.sh -p ` configures all of the above idempotently against a running slapd (auto-detects the database holding your base DN, and verifies `pwdAccountLockedTime` is live — the attribute the app's active/inactive toggle depends on). ## Backups and restore `ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention) — see the *Backups and restore* section of `DEPLOYMENT.md`. The manual LDAP-only steps below are what it does under the hood, useful if you want just the directory without Redis/config. **Backup** (while slapd is running): ```bash docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif ``` Store the `.ldif` off the host — it contains every user's password hash. **Restore** into a stopped directory. The SSO image uses a static `slapd.conf` (slapd starts with `-f`, not cn=config `-F`), so restore uses `slapadd -f`: ```bash docker compose stop sso-manager docker compose run --rm --no-deps --entrypoint sh sso-manager -c \ 'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \ < ldap-backup-.ldif docker compose start sso-manager ``` Verify: `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`. Redis state (OAuth clients, tokens) and `./config/` secrets are backed up separately — see the *Backups and restore* section of `DEPLOYMENT.md` for the full (LDAP + Redis + secrets) runbook. ## Troubleshooting ### `503 OpenLDAP ppolicy overlay is not configured` The ppolicy overlay isn't attached to the database holding your users, so the active/inactive toggle can't set `pwdAccountLockedTime`: ```bash sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com ``` ### LDAP connection refused ```bash docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base' systemctl status slapd # bare metal ``` [← Back to Home](index.html)