think
16px
820px

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)

  1. POST /auth/login {email, password} → try LOCAL password first (unchanged path).
  2. On unknown user OR local-verify failure, and LDAP enabled: service-account search bind
    (LDAP_BIND_DN/LDAP_BIND_PASSWORD) under LDAP_BASE_DN using LDAP_USER_FILTER
    (default AD-friendly: matches mail OR sAMAccountName; the input is escaped with
    ldap.EscapeFilter — no filter injection). Zero or >1 results → normal invalid-credentials.
  3. Bind as the found user DN with the submitted password. Failure → invalid-credentials
    (same generic error as local; no user enumeration).
  4. Success → JIT provision or fetch: provider='ldap', email + display name from attributes
    (mail, displayName by 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 by users.provider).
  5. 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 for provider='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 in ldap_role_grants), revoke roles present in
    ldap_role_grants whose 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.goLDAPAuthenticator (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: a DirectoryAuthenticator port (Authenticate(ctx, login, password) →
    {Email, DisplayName, Groups} or error) + LoginWithDirectory orchestration 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 ldap running osixia/openldap seeded 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.