LDAP / Active Directory Authentication — Design
Date: 2026-07-04 · Status: Approved (brainstorm; login flow = transparent same-form + admin-selectable LDAP-only mode)
Module: CORE platform (no license gate). Queue position 1 (then SCIM, then observability).
Goal
Enterprise directory login: users authenticate with their AD/LDAP credentials on the
existing login form, are JIT-provisioned on first success, and receive Obscura roles from
their AD groups. Admins can flip an LDAP-only posture that disables local passwords for
everyone except admin-role holders (break-glass).
Login flow (transparent, same form)
POST /auth/login {email, password}→ try LOCAL password first (unchanged path).- On unknown user OR local-verify failure, and LDAP enabled: service-account search bind
(LDAP_BIND_DN/LDAP_BIND_PASSWORD) underLDAP_BASE_DNusingLDAP_USER_FILTER
(default AD-friendly: matchesmailORsAMAccountName; the input is escaped with
ldap.EscapeFilter— no filter injection). Zero or >1 results → normal invalid-credentials. - Bind as the found user DN with the submitted password. Failure → invalid-credentials
(same generic error as local; no user enumeration). - Success → JIT provision or fetch:
provider='ldap', email + display name from attributes
(mail,displayNameby default), no password hash stored. Existing local user with
the same email: the LOCAL account wins at step 1; if its local verify failed, do NOT
ldap-bind for it (a user is either local or ldap — decided byusers.provider). - Group→role reconciliation (below), then the normal session + audit path.
- TOTP: unchanged — a user's TOTP challenge applies after either backend succeeds.
- Change-password / forgot-password: server rejects forprovider='ldap'users with a
clear error (password lives in AD); the profile UI hides the panel for them.
-LDAP_MODE=only: local-password login is rejected for everyone EXCEPT users holding
the admin role (break-glass — a downed DC can never lock out the operator). dev-login
unaffected (development env only).
- Rate limiting: the existing per-IP/user auth limits cover the LDAP path (same handler).
Config (env, fail-closed at boot)
| Var | Meaning |
|---|---|
LDAP_ENABLED |
default false |
LDAP_URL |
ldap://host:389 or ldaps://host:636 |
LDAP_START_TLS |
upgrade plain 389 to TLS (mutually exclusive with ldaps) |
LDAP_CA_FILE |
optional PEM for private CAs |
LDAP_INSECURE_SKIP_VERIFY |
testing only, logged loudly |
LDAP_BIND_DN / LDAP_BIND_PASSWORD |
read-only service account for the search bind |
LDAP_BASE_DN |
search base, e.g. dc=corp,dc=example,dc=id |
LDAP_USER_FILTER |
default (&(objectClass=user)(|(mail=%s)(sAMAccountName=%s))); %s = escaped login |
LDAP_ATTR_EMAIL / LDAP_ATTR_NAME / LDAP_ATTR_GROUPS |
defaults mail / displayName / memberOf |
LDAP_MODE |
mixed (default) or only |
Enabled-but-unusable config (missing URL/base DN, ldaps+StartTLS both set) → boot fails
loudly, consistent with every other provider.
Group → role mapping
- Migration (next free number):
ldap_group_roles(group_dn text PK, role_id fk)+
ldap_role_grants(user_id, role_id, group_dn, granted_at, PK(user_id, role_id)). - On every LDAP login: read the user's
memberOf(direct groups only, v1), match against
ldap_group_roles(case-insensitive DN compare), then reconcile: grant missing
mapped roles (recorded inldap_role_grants), revoke roles present in
ldap_role_grantswhose group no longer matches. Manually-assigned roles are never
touched (they have no grants row). Reconciliation errors are logged and NON-FATAL to the
login (authz simply stays stale until next login). - Admin → Directory (LDAP) section (existing admin page pattern): read-only status card
(enabled, URL, mode, last successful bind) + mapping CRUD table (group DN ↔ role picker).
New endpoints:GET /admin/ldap/status,GET/PUT/DELETE /admin/ldap/group-roles
(admin-gated with the same perm the roles manager uses). - Positions / org tree stay Obscura-managed; AD maps to ROLES only.
Implementation shape
go/internal/auth/adapters/ldap.go—LDAPAuthenticator(connect/TLS, search bind,
user bind, attribute + group fetch), constructed in wire.go from config; nil when
disabled. One new Go dependency:github.com/go-ldap/ldap/v3(MIT, pure Go).auth/app: aDirectoryAuthenticatorport (Authenticate(ctx, login, password) →
{Email, DisplayName, Groups} or error) +LoginWithDirectoryorchestration in the
existing login service path; JIT provisioning reuses the OIDC provisioning shape
(provider='ldap').- rbac: reuse the existing role-binding store for grant/revoke; the grants ledger lives in
the auth (or rbac) context — follow whichever owns role bindings today. - e2e rig: compose profile
ldaprunningosixia/openldapseeded with an LDIF (2 users,
2 groups) — same opt-in pattern as the OIDC test IdPs. Not part of the default stack.
Error handling
| Case | Behavior |
|---|---|
| LDAP server unreachable | local logins unaffected; LDAP attempts → generic invalid-credentials + WARN log (mixed mode); admins still get in under only mode |
| 0 or >1 search results | generic invalid-credentials (no enumeration) |
| Group fetch fails post-bind | login SUCCEEDS, reconciliation skipped, WARN log |
| Config invalid | boot failure (fail-closed) |
| LDAP user tries change-password | 400 with "managed by your directory" error code |
Testing (repo discipline: never go test on the live DB)
Build/vet + tsc/vite; e2e against the compose ldap profile: first-login JIT (+provider
tag, no hash), wrong password, unknown user, group→role grant, group removal → revoke on
next login, manual role untouched, LDAP_MODE=only (non-admin local rejected, admin
break-glass works, ldap user works), TOTP-enrolled LDAP user challenged, change-password
rejected for ldap user. Demo stays intact (LDAP disabled by default in deploy env).
Out of scope (deliberate)
Periodic/background user sync (SCIM, queue #2), nested/transitive group resolution,
attribute sync beyond email+name, multiple LDAP servers/failover, LDAP for 2FA, SCIM.