The Integrations page's Service Accounts (bind-only, organizationalRole)
don't cover the other real use case: an account something actually runs
as on a Linux host -- a media manager, a torrent client, Emby -- with a
real uidNumber/gidNumber that owns files, and a group other accounts
join for write access (e.g. a `stuff_manager` group granting write
rights to a media library). That needs a real posixAccount, which the
bind-only model can't be.
- New well-known group `app_sso_service_account`, seeded the same way as
app_sso_admin/app_sso_invite/app_sso_oauth_admin (docker-entrypoint.sh,
ops/ldap-setup.sh). Not a permission gate -- a marker.
- "Add new user" form gets a "This is a service account" checkbox: swaps
the person-shaped fields (first/last name, birthday, ToS agreement)
for a single account-name field, since none of those make sense for a
non-person account. On create, the route adds the user to
app_sso_service_account.
- User.listDetail() annotates each user with isServiceAccount (checked
against the marker group's member list once per call, not the memberof
overlay's reverse attribute -- not reliably returned by every LDAP
server this app might point at, confirmed against a real external
directory during testing). Users page shows a "service" badge.
- Notification broadcasts (filter_type=all/all_active) exclude service
accounts by default -- nobody reads mail as `stuff_manager`.
- Fixed a real, previously-unrelated bug this surfaced: addPosixAccount
unconditionally set `mail: data.mail` in the LDAP entry even when
undefined, and ldapts/slapd reject an attribute given an explicit
undefined value ("no values for attribute type") rather than treating
it as absent. This meant creating ANY user without an email already
failed outright -- not something a service account (which commonly has
no real mailbox) could route around. Made mail conditional, matching
how mobile/sshPublicKey/dob already work.
- docs/ldap.md now explains both kinds of service account side by side
and when to use which.
Verified end-to-end against a real external LDAP server (not a local
sandbox): created a service account with no email, confirmed it's
correctly flagged and excluded from broadcast recipient resolution,
confirmed a normal user is unaffected, confirmed the code degrades
gracefully if the marker group doesn't exist yet (pre-upgrade
deployments).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.9 KiB
layout, title
| layout | title |
|---|---|
| default | LDAP |
LDAP Directory
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 legacy apps that bind LDAP
directly — 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=<uid>,ou=people,<base> and carry the objectClasses:
inetOrgPerson(cn, sn, mail, …) — identity / contact attrs.posixAccount(uid, uidNumber, gidNumber, homeDirectory) — the SSO'suserFilteris(objectClass=posixAccount), so a user is "a real account" iff it hasposixAccount.ldapPublicKey— SSH public keys (sshPublicKey).sudoRole— per-user sudo rules (sudoCommand,sudoHost,sudoUser).theta42Person(custom auxiliary;dateOfBirth).
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=<name>,ou=groups,<base> (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. Admin permission checks read the group's member list, not
memberOf on the user.
The SSO requires three groups (seeded automatically by the entrypoint /
install.sh):
| Group | Grants |
|---|---|
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) |
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:
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:
volumes:
- ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
The entrypoint leaves existing certs untouched (idempotent).
Service accounts
There are two different kinds of "not a real person" account, and which one you want depends on what's consuming it:
LDAP bind-only — for an app that just needs to bind LDAP to look users up
(its own "LDAP authentication" settings page, or the read-only account
theta42/ldap-client binds as). Not a posixAccount — no uidNumber, no
home directory, can't log into this UI. Create one from the
Integrations → LDAP tab's Service Accounts section (create, rotate
password, delete). theta-env's bootstrap creates cn=ldapclient this same
way automatically, and the proxy binds as it — don't reuse the admin DN for
this.
Unix/POSIX — for an account something actually runs as on a Linux
host: a media manager, a torrent client, a service like Emby — 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). Create one from the Users page'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 a normal posixAccount, just flagged (via
membership in the app_sso_service_account group) so it's visibly marked in
the Users list and excluded from "all users" notification broadcasts.
Either way: don't reuse the admin DN, and give it only the group memberships it actually needs.
Example bind test (LDAP bind-only account):
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, applied to
your domain:
| Field the app asks for | Value |
|---|---|
| Host / URL | ldaps://<your-sso-host>:636 (preferred), or ldap://<host>:389 + StartTLS |
| Bind DN | a dedicated service account — e.g. cn=ldapclient,ou=people,<base> (see above) |
| Bind password | that service account's password |
| User search base | ou=people,<base> |
| User search filter | (objectClass=posixAccount) |
| Username attribute | uid |
| Email attribute | mail |
| Group search base | ou=groups,<base> |
| 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):
environment:
LDAP_URL: ldaps://sso.example.com:636
LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com
LDAP_BIND_PASSWORD: <service-account-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: 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. - Custom schema: the
theta42Personauxiliary objectClass withdateOfBirth— seeops/ldap-setup.shfor the LDIF. - Directory tree:
ou=people,ou=groups,ou=policiesunder the base DN, a defaultpwdPolicyatcn=ppolicy,ou=policies,<base>. - Required groups:
app_sso_admin,app_sso_invite,app_sso_oauth_admin.
ops/ldap-setup.sh -p <admin-password> 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
Backup (while slapd is running):
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:
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-<date>.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:
sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com
LDAP connection refused
docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
systemctl status slapd # bare metal