432 lines
18 KiB
Markdown
432 lines
18 KiB
Markdown
---
|
|
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=<uid>,ou=people,<base>` 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=<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.
|
|
|
|
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 `<slug>_admin`
|
|
group, and each `<slug>_admin` into its `<slug>_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=<uid>,ou=groups,<base>`, `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 `<uid>`'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://<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):
|
|
|
|
```yaml
|
|
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](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,<base>
|
|
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,<base>`.
|
|
- **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 <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
|
|
|
|
`ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention)
|
|
for standalone deployments — 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-<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`:
|
|
|
|
```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) |