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.
Login flow
apps/bff/src/user/ldap.ts:
- Service bind (if
bindDnis set) or anonymous bind. Used to search for the user’s DN. - User search — apply
userFilteragainstuserBaseDn, substituting{username}(RFC 4515 escaped). Expect exactly one result; multiple matches abort withnull. - User bind — bind directly as the discovered DN with the typed password. A successful bind proves the password.
- Group resolution — per
groupStrategy:memberOf: read thememberOfattribute from the user entry (AD-style).search: searchgroupBaseDnfor groups whosememberAttrcontains the user’s DN (OpenLDAP-style).
- Group → role mapping — walk
groupMappingsin order. First match wins per mapping (a user matching multiple mappings gets the union of their roles). - Return
{ username, roles }on success,nullon any failure.
A failure at any step returns null — the UI shows a generic “Invalid credentials” message. No information leak about which step failed.
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). |
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 probing
The BFF exposes GET /api/auth/health (polled by the login page every 5 seconds):
local: backend is local — returnsreachable: trueunconditionally.ldap reachable: last LDAP probe succeeded.ldap unreachable: last probe failed.
The health probe runs:
- TCP / TLS connect to
ldap.url. - Service bind (or anonymous bind).
- (Optional username resolver) A test search for a known username when invoked from the admin Auth Status page.
Probe failure is the trigger condition for Break-Glass Access.
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.- 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.