LDAP Backend
The LDAP backend authenticates users against an external directory (OpenLDAP, Active Directory, FreeIPA, 389-DS) and derives Horizon roles from LDAP group membership. Passwords are never stored by Horizon — login binds as the user with their typed password.
Activate
auth:
backend: ldap
ldap:
url: ldaps://ldap.corp:636
bindDn: "cn=horizon,ou=services,dc=corp"
bindPassword: "${HORIZON_LDAP_BIND_PW}"
userBaseDn: "ou=people,dc=corp"
userFilter: "(uid={username})"
displayNameAttr: cn
groupStrategy: memberOf
groupBaseDn: ""
memberAttr: member
timeoutMs: 5000
tlsInsecure: false
groupMappings:
- { group: "cn=horizon-admin,ou=groups,dc=corp", role: admin }
- { group: "cn=sre,ou=groups,dc=corp", role: operator }
- { group: "cn=platform,ou=groups,dc=corp", role: maintainer }
- { group: "*", role: viewer }
Bootstrap rule: ldap.groupMappings must be non-empty before LDAP users can sign in. The BFF boots and surfaces the setup-required state on the login page, but no LDAP login succeeds until at least one mapping is configured.
How sign-in works
Horizon never stores or reads stored passwords. A sign-in attempt authenticates as the user against the directory with the typed password, so the directory itself decides whether the password is valid. On success, Horizon reads the user’s group memberships and maps them to roles via groupMappings.
What this means when you configure LDAP:
- Group membership is read with the service account (
bindDn), not the user’s own credentials. Many directories deny ordinary users read access to the group subtree, so the service account must be able to see groups — otherwise every user falls back to the*role. userFiltermust resolve to a single user. If it matches more than one entry, the first match is used.- Roles are the union of all matching
groupMappings— a user in two mapped groups gets both roles’ permissions. - Failures are deliberately indistinguishable. A wrong password, a missing user, or no matching group all surface the same “Invalid credentials” message; the specific cause is never revealed to the browser.
Field reference
See auth for the field table.
userFilter recipes
| Directory | Filter |
|---|---|
| OpenLDAP / POSIX | (uid={username}) |
| Active Directory (sAMAccountName) | (sAMAccountName={username}) |
| Active Directory (UPN) | (userPrincipalName={username}) |
| Email-as-username | (mail={username}) |
| Either uid or email | (|(uid={username})(mail={username})) |
{username} is the literal placeholder — substituted at runtime with the typed username, escaped per RFC 4515. Do not pre-escape or quote.
groupStrategy choice
| Strategy | When to use |
|---|---|
memberOf |
Active Directory and most modern OpenLDAP deployments. User entries carry a memberOf multi-valued attribute. Faster (single read, no second search). |
search |
OpenLDAP deployments where users do not carry memberOf. Requires groupBaseDn and uses memberAttr (usually member or uniqueMember). The service account (bindDn) — not the logging-in user — performs this search, so it must have read access to the group subtree. |
When unsure, try memberOf first; if a successful user bind returns no groups, switch to search.
Group mappings
groupMappings:
- { group: "cn=horizon-admin,ou=groups,dc=corp", role: admin }
- { group: "cn=sre,ou=groups,dc=corp", role: operator }
- { group: "cn=platform,ou=groups,dc=corp", role: maintainer }
- { group: "*", role: viewer }
- Exact DN match on
group, case-insensitive. "*"is a special fallback — matches any authenticated user. Use as the last entry to give everyone at leastviewer.- A user matching multiple groups gets the union of all matching roles. E.g., a user in both
cn=sreandcn=platformends up withoperatorandmaintainerroles (effective verbs are the union of both role’s grants). - Order matters only in the sense of being listed; all matching entries contribute.
Health and directory reachability
The login page continuously reflects whether the directory is reachable, so operators see an outage before a user reports a failed login. When the directory is unreachable, that state is also what arms Break-Glass Access.
The admin Auth Status page lets you confirm the connection (and the service bind) and test a username against the live directory — it shows the groups returned and the roles those groups resolve to, without the user needing to sign in. Use it to debug groupMappings and to verify the service account can read the group subtree.
TLS
ldaps://(TLS-on-connect) is the recommended scheme. Default port 636.ldap://with StartTLS upgrade is not currently supported — useldaps://.tlsInsecure: truedisables certificate validation. Only for dev with self-signed certs. Never in production.
If the LDAP server uses a private CA, the BFF process must trust it via the OS / Node trust store. Set NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.pem to inject a CA without modifying the system store.
Operations
| Action | How |
|---|---|
| Add a role grant | Append to groupMappings. Hot-reload picks it up; the next new session uses the new mapping. Existing sessions keep their captured role list — they pick up changes on re-login. |
| Move a user between LDAP groups | Handled by your LDAP admin tool, not Horizon. Next login resolves the new group set. |
| Test “what roles will user X get?” | Admin → Auth Status page has a username resolver — type a username, see the groups returned by LDAP and the resolved Horizon roles. No login required. |
| Trace a login failure | Audit log entry (auth.login, outcome failure) carries source IP and timestamp. No password is logged. For LDAP-side debugging, enable LDAP server logging on your directory. |
Wire-up to OAP
OAP does not see Horizon’s LDAP credentials. The user authenticates against the directory at the Horizon layer; OAP receives requests with whatever credentials are set in oap.auth (typically a single service account). See Setup → oap.
Common mistakes
- Service bind fails silently. Wrong
bindDnorbindPasswordcauses all logins to fail with a generic message. Verify by looking at LDAP server logs. groupStrategy: memberOfon a directory that doesn’t populate it. Logins succeed but every user gets only the"*"fallback role. Switch tosearch.searchstrategy with a locked-down group subtree. Group resolution runs on the service account (bindDn), so grant that account read access togroupBaseDn. (The logging-in user does not need it — Horizon never uses the user’s own bind for group lookup.)- Forgetting the
"*"fallback. A user who authenticates but matches no group mapping is rejected — change tonulland the UI shows “Invalid credentials”. Add"*" → viewerfor graceful degradation. tlsInsecure: truein production. A man-in-the-middle on the LDAP connection can capture every typed password. Use proper certificates instead.