A lightweight, adapter-based LDAP client for Go. LDAPper wraps
go-ldap/ldap with:
- Context-aware operations — every networked call takes a
context.Contextas its first argument; cancellation kills the underlying connection deterministically. - Multi-host failover and transparent reconnection.
- Pluggable catalog adapters so the same API works against Microsoft Active Directory, OpenLDAP, ALD Pro, FreeIPA, and any other directory you write an adapter for.
- Connection-level search limits (
SizeLimit,TimeLimit,MaxPages,MaxEntries,RequirePaging) injected once at construction time. - Client-side operation timeouts applied around individual
Bind/Searchcalls, independent of server-sideTimeLimit. - Efficient catalog tree discovery — a single paged subtree search
assembled into an
OUtree on the client, with optional depth trimming. - Sentinel errors (
ErrNotFoundOrNotVisible,ErrUnavailable,ErrInvalidCredentials, …) designed forerrors.Is/errors.As. - End-to-end integration tests for every shipped adapter, running against real directory servers in Docker (OpenLDAP, Samba 4 AD DC, FreeIPA, and OpenLDAP-with-ALDPro-schema).
| Layer | Where it runs | Build tag | Notes |
|---|---|---|---|
| Unit tests | go test -race ./... (GitHub Actions, every push) |
— | No external dependencies; includes the race detector. |
| Integration — OpenLDAP | go test -tags=integration_openldap ./... |
integration_openldap |
Spins up osixia/openldap:1.5.0 via testcontainers. |
| Integration — MS AD | go test -tags=integration_msad ./... |
integration_msad |
Spins up a Samba 4 AD DC; gracefully skips if Docker is unavailable. |
| Integration — FreeIPA | go test -tags=integration_freeipa ./... |
integration_freeipa |
Spins up a FreeIPA server; heavy image, gracefully skips on failure. |
| Integration — ALDPro | go test -tags=integration_aldpro ./... |
integration_aldpro |
OpenLDAP + custom ALDPro schema. |
Coverage is measured with go test -coverprofile=coverage.out ./... and
uploaded to Codecov on every push to main and on pull requests. The
current coverage report is available at
codecov.io/gh/NGRsoftlab/ldapper.
The default go test ./... is intentionally fast and Docker-free: all
integration suites live behind build tags so they do not compile into
the default test binary (Go allows only one TestMain per package, so
the tags are also what keeps the four suites from colliding).
- Installation
- Quick start
- Architecture
- Core concepts
- Shipped adapters
- API overview
- Errors
- Writing your own adapter
- Configuration & tuning
- Testing
- License
go get github.com/NGRsoftlab/ldapperRequires Go 1.26 or newer (see go.mod). Depends on
github.com/go-ldap/ldap/v3. Integration tests additionally require
github.com/testcontainers/testcontainers-go and a running Docker daemon.
package main
import (
"context"
"crypto/tls"
"log"
"time"
"github.com/NGRsoftlab/ldapper"
"github.com/NGRsoftlab/ldapper/adapters"
)
func main() {
ctx := context.Background()
adapter := adapters.MSADAdapter{}
limits := ldapper.DefaultSearchLimits()
limits.TimeLimit = 30
conn, err := ldapper.NewAuthenticatedLDAPper(
ctx,
"svc-reader@corp.example.com", "secret", // bind credentials
"DC=corp,DC=example,DC=com", // base DN for probe and user lookups
[]string{"dc1.corp.example.com", "dc2.corp.example.com"},
636, // port
true, // useLDAPS
false, // skipStartTLS (must be false when useLDAPS is true)
&tls.Config{MinVersion: tls.VersionTLS12},
adapter,
ldapper.WithSearchLimits(limits),
ldapper.WithDialTimeout(5*time.Second),
)
if err != nil {
log.Fatalf("connect: %v", err)
}
defer conn.Close()
u, err := conn.GetUser(ctx, "jdoe", "DC=corp,DC=example,DC=com")
if err != nil {
log.Fatalf("get user: %v", err)
}
info := adapter.GetNormalizedUserInfo(*u)
log.Printf("%s <%s>", info.CommonName, info.Mail)
}LDAPper is intentionally small and layered. Only two things are pluggable
(interfaces.LDAPConn and interfaces.CatalogAdapter); everything else is
plain structs and functions.
┌────────────────────────────────────────────────────────────────────┐
│ User code │
│ conn, _ := ldapper.NewAuthenticatedLDAPper(ctx, ...) │
│ conn.GetUser(ctx, ...) / conn.GetCatalogTree(ctx, ...) / ... │
└──────────────────────────────────┬─────────────────────────────────┘
│
┌──────────────────────────────────▼─────────────────────────────────┐
│ LDAPper — public API │
│ │
│ ldapper.go constructors + LDAPper struct │
│ ldapper_connection.go dial / failover / IsAlive / │
│ Reconnect / EnsureAlive / Close │
│ ldapper_auth.go BindConn, │
│ CheckUserCredentials │
│ ldapper_search.go searchPaged, │
│ searchSingleEntryLocked, │
│ newSearchRequest, CustomSearch, │
│ CustomSearchPaged │
│ ldapper_user_ops.go GetUser, GetUserGroups, │
│ GetOUUsers │
│ ldapper_group_ops.go GetGroup │
│ ldapper_ou_ops.go GetOU │
│ ldapper_catalog_tree_discovery.go GetCatalogTree, GetSubOUs, │
│ ListSingleLevelOUs, │
│ WalkCatalogTree │
│ ldapper_options.go Option, SearchLimits, │
│ OperationTimeouts, With/Set/Get │
│ ldapper_errors.go sentinel errors + classifiers │
│ helpers.go attrsToMap, escapeFilterArgs, │
│ parentDNOf, canonicalDN │
│ validators.go validateDN / validateBaseDN / │
│ validateHost / formatHost │
└──────────────┬────────────────────────────────────┬────────────────┘
│ │
uses │ │ uses
▼ ▼
┌──────────────────────────────┐ ┌───────────────────────────────┐
│ interfaces.LDAPConn │ │ interfaces.CatalogAdapter │
│ (thin subset of go-ldap) │ │ (schema-specific glue) │
│ │ │ │
│ StartTLS / Bind / Search / │ │ Filters / Attrs / Casts / │
│ SetTimeout / IsClosing / │ │ BindIdentity │
│ Close │ │ │
└──────────────┬───────────────┘ └──────────────┬────────────────┘
│ │
prod: *ldap.Conn prod: adapters.{MSAD, OPENLDAP,
test: mocks.MockLDAPConn ALDPRO, FREEIPA}Adapter
test: fakeAdapter
(ldapper_auth_test.go)
│
┌──────────────────────────────▼─────────────────────────────────────┐
│ go-ldap/ldap/v3 │
└──────────────────────────────┬─────────────────────────────────────┘
│
LDAP server
The two interfaces are the only seams:
interfaces.LDAPConnis a 6-method subset of*ldap.Conn. Production uses the real*ldap.Conn; tests injectmocks.MockLDAPConn.interfaces.CatalogAdapteris the only place LDAPper knows anything about directory schema. All filters, attribute lists, value casts and bind-principal translation go through it.
LDAPper
├── opMu sync.Mutex serializes all ops
│
├── catalogAdapter interfaces.CatalogAdapter schema-specific behavior
├── connection interfaces.LDAPConn active network connection
│
├── isAuthenticated bool set by BindConn
├── bindUser string cached for re-bind on
├── bindPassword string Reconnect /
├── bindDN string CheckUserCredentials
│ (base DN used by
│ CheckUserCredentials)
│
├── hosts []string failover list (≤ 50)
├── currentHost string host of the active conn
├── port uint
│
├── useLDAPS bool LDAPS vs plain LDAP
├── skipStartTLS bool disable StartTLS upgrade
├── tlsCfg *tls.Config
│
├── searchLimits SearchLimits injected via newSearchRequest
├── dialTimeout time.Duration used by dialHost
└── opTimeouts OperationTimeouts applied by withCtx
Every search method funnels through newSearchRequest(...), which injects
searchLimits. Changing limits at runtime (SetSearchLimits) is
therefore a single assignment with no plumbing.
bindDN is a subtle but useful field: BindConn(ctx, username, password, baseDN) records baseDN so that later calls to
CheckUserCredentials(ctx, username, password) can look users up under
the same base DN without the caller repeating it.
dialTimeout is initialised to constants.DefaultDialTimeout (10s) by
the constructors. WithDialTimeout(0) (or SetDialTimeout(0)) disables
the explicit timeout and falls back to Go's net.Dialer default.
opTimeouts starts at zero (Bind: 0, Search: 0) which means "no
client-side timeout" — the operations rely on SearchRequest.TimeLimit
(server-side) and the caller's context deadline. Enabling
WithOperationTimeout adds a client-side deadline around each individual
Bind / Search call.
conn.GetUser(ctx, "jdoe", baseDN)
│
├── catalogAdapter.SearchUserFilterTemplate()
│ → "(uid=%s)" → fmt.Sprintf → "(uid=jdoe)"
├── catalogAdapter.UserAttrs()
│ → []string{"cn", "mail", ...}
│
▼
searchSingleEntryLocked(ctx, kindUser, "jdoe", baseDN, template, attrs)
│
│ SizeLimit is hardcoded to 2 (ambiguity probe);
│ TimeLimit comes from conn.searchLimits.
▼
withCtxResult(ctx, opTimeouts.Search, …)
│
│ watchdog goroutine closes the connection
│ if ctx is cancelled mid-flight
▼
conn.connection.Search(req) ─────────▶ LDAP server
│
▼
0 entries → ErrNotFoundOrNotVisible
("user \"jdoe\" not found or not visible")
1 entry → types.LDAPUser{DN, Attributes}
>1 entries → ErrAmbiguousResult
("too many entries for user ...")
conn.GetCatalogTree(ctx, baseDN, maxDepth)
│
▼
loop:
Search(baseDN, WholeSubtree, paging=1000)
│
├── server echoes ControlPaging → read cookie
│
▼
next request with cookie … until empty cookie
│
▼
allEntries []*ldap.Entry
│
▼
for each entry:
catalogAdapter.GetNormalizedOUInfoFromEntry(entry)
drop if !baseDN.AncestorOfFold(entry.DN) (caseIgnoreMatch)
byDN[canonicalDN(info.DN)] = info
│
▼
attach each node to parentDNOf(info.DN) → tree roots
│
▼
DFS walk respects maxDepth (0 = no limit)
│
▼
types.CatalogTree{Tree: [...]}
Two important details:
canonicalDNnormalises attribute types and values to lowercase, sorts multi-valued RDN attributes bytype=value, trims whitespace, and preserves RDN order. This is not a full RFC 4517 matching-rules implementation — it deliberately over-merges case-differing DNs so equivalent entries link correctly. Malformed DNs fall back to a lowercased raw string.AncestorOfFold(notAncestorOf) is used so case-differing ancestors still match — otherwise subtree entries with different attribute-type case would be silently dropped.
This is the whole reason GetCatalogTree is O(1) round-trips instead of
O(N): the server streams pages of the subtree, and the parent-child
wiring is done locally in O(N) with a single map[string]*OUNormalizedInfo.
LDAPper is created with one of two constructors. Both take a
context.Context as their first argument; that context governs the
initial dial (and, for the authenticated variant, the initial Bind).
// Unauthenticated: dials a host but performs no bind. Suitable for
// directories that allow anonymous reads.
func NewLDAPper(
ctx context.Context,
hosts []string,
port uint,
useLDAPS, skipStartTLS bool,
tlsCfg *tls.Config,
catalogAdapter interfaces.CatalogAdapter,
opts ...Option,
) (*LDAPper, error)
// Authenticated: dials, then binds as `username` and probes `baseDN`
// with a base-object search to confirm the tree is reachable under the
// bound identity. No post-bind user lookup is performed, so service
// principals that do not match UserObjectFilter are accepted. No user
// entry is returned — call GetUser to fetch it. `username` and `baseDN`
// are cached for later CheckUserCredentials calls.
func NewAuthenticatedLDAPper(
ctx context.Context,
username, password, baseDN string,
hosts []string,
port uint,
useLDAPS, skipStartTLS bool,
tlsCfg *tls.Config,
catalogAdapter interfaces.CatalogAdapter,
opts ...Option,
) (*LDAPper, error)TLS matrix — useLDAPS and skipStartTLS together select the transport:
useLDAPS |
skipStartTLS |
tlsCfg |
Result |
|---|---|---|---|
true |
false |
required | LDAPS — TLS from the first byte |
true |
true |
— | rejected (ErrInvalidArgument) |
false |
false |
required | Plain dial followed by StartTLS |
false |
true |
ignored | Plaintext LDAP (no TLS) |
Other constructor rules:
- Host failover — hosts are tried in order until one accepts the
connection. The list is capped at
constants.DefaultMaxHostsLength(50) to guard against accidentally passing a million hosts. - Port — must be in
[1, 65535]. - Host strings — validated against URL-special characters; IPv6
literals are accepted without brackets (they are bracketed for the URI
internally by
formatHost). dialConnis a package-level variable (of typeDialFunc) so tests can swap it out viawithDialConn(t, fn)without touching production code.
Mutual TLS works with no LDAPper-specific API: the *tls.Config passed
to NewLDAPper / NewAuthenticatedLDAPper is Clone()d and then used
verbatim by both transports —
- LDAPS (
useLDAPS=true) —tls.Dialer{Config: tlsCfg}indialHost. - StartTLS (
useLDAPS=false, skipStartTLS=false) —conn.StartTLS(tlsCfg)immediately after the plain dial.
Client authentication is configured exactly as in the standard library:
put the client certificate into tls.Config.Certificates (or
tls.Config.GetClientCertificate), and the CA that signed the server
certificate into tls.Config.RootCAs.
cert, err := tls.LoadX509KeyPair("client.crt", "client.key")
if err != nil {
log.Fatalf("load client cert: %v", err)
}
caPool := x509.NewCertPool()
caPEM, err := os.ReadFile("ca.crt")
if err != nil {
log.Fatalf("read CA: %v", err)
}
if !caPool.AppendCertsFromPEM(caPEM) {
log.Fatal("CA bundle contains no valid certificates")
}
tlsCfg := &tls.Config{
// Optional for LDAPS: ServerName is inferred from the dialed host.
// Set it explicitly when `hosts` holds an IP or a name that does not
// appear in the server certificate's SAN.
ServerName: "ldap.example.com",
RootCAs: caPool,
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
conn, err := ldapper.NewAuthenticatedLDAPper(
ctx,
"svc-reader@corp.example.com", "secret", "DC=corp,DC=example,DC=com",
[]string{"ldap.example.com"}, 636,
true, // useLDAPS
false, // skipStartTLS (must be false when useLDAPS is true)
tlsCfg,
adapters.MSADAdapter{},
)ServerName is required for StartTLS: go-ldap calls
tls.Client(conn, cfg) without a ServerName hint, so the standard
library rejects the handshake with
tls: either ServerName or InsecureSkipVerify must be specified in the tls.Config
unless one of the two fields is set. InsecureSkipVerify: true defeats
the point of TLS — prefer ServerName.
tlsCfg := &tls.Config{
ServerName: "ldap.example.com", // required for StartTLS
RootCAs: caPool,
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
conn, err := ldapper.NewLDAPper(
ctx, []string{"ldap.example.com"}, 389,
false, // useLDAPS
false, // skipStartTLS → StartTLS upgrade
tlsCfg,
adapters.OPENLDAPAdapter{},
)NewLDAPper stores a Clone() of the provided tls.Config. The clone
is shallow — the Certificates slice and the DER bytes inside each
tls.Certificate are shared with the caller — so do not mutate the
certificate bytes after passing them in. For hot rotation (common with
short-lived mTLS certificates issued by Vault / SPIRE / a custom CA),
use tls.Config.GetClientCertificate instead of Certificates; the
callback is invoked on every handshake, and Clone() copies it by
reference:
type certRotator struct {
mu sync.RWMutex
cur *tls.Certificate
}
func (r *certRotator) load(crtPath, keyPath string) error {
c, err := tls.LoadX509KeyPair(crtPath, keyPath)
if err != nil {
return err
}
r.mu.Lock()
r.cur = &c
r.mu.Unlock()
return nil
}
func (r *certRotator) get(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.cur, nil
}
tlsCfg := &tls.Config{
ServerName: "ldap.example.com",
RootCAs: caPool,
GetClientCertificate: rotator.get,
MinVersion: tls.VersionTLS12,
}To pick up new material, reload it in the rotator and call
conn.Reconnect(ctx): reconnect performs a fresh TLS handshake against
ldapper.tlsCfg, so the callback serves the new certificate
immediately. Cached bind credentials (see Connections)
survive the reconnect — Reconnect replays the bind before publishing
the connection, and a failed rebind still invalidates auth state and
returns ErrInvalidCredentials, exactly as for non-mTLS connections.
TLS handshake failures — including "server rejected client certificate"
— flow through classifyLDAPError and surface as ErrUnavailable
(transport class). They are not distinguishable from a network outage
via errors.Is today. Use errors.As against *tls.CertificateVerificationError
or an x509 error type if you need the root cause:
var cvErr *tls.CertificateVerificationError
if errors.As(err, &cvErr) {
log.Printf("TLS verification failed: %v", cvErr)
}Note:
tls.Config.ClientAuthis a server-side field. Setting it on the client has no effect — the certificate presented by the client is selected fromCertificates/GetClientCertificate, not byClientAuth.
All directory-specific behavior lives in an adapter — a value that
implements interfaces.CatalogAdapter:
type CatalogAdapter interface {
// Cast / convert
GetNormalizedUserInfo(ldapUser types.LDAPUser) *types.UserNormalizedInfo
GetNormalizedOUInfoFromEntry(entry ldap.Entry) *types.OUNormalizedInfo
// Object-class filters
UserObjectFilter() string
GroupObjectFilter() string
OUObjectFilter() string
// Search filter templates
//
// Contract: templates use fmt-style positional verbs (%[N]s). All
// arguments are escaped with ldap.EscapeFilter by LDAPper before
// substitution, so implementers must return raw (unescaped) attribute
// values.
SearchUserFilterTemplate() string // 1 arg
SearchUserGroupsFilterTemplate() string // N args, use with the args helper
SearchUserGroupsFilterTemplateArgs(ldapUser types.LDAPUser) []any
SearchOUFilterTemplate() string // 1 arg
SearchGroupFilterTemplate() string // 1 arg
// Attribute lists
UserAttrs() []string
OUAttrs() []string
GroupAttrs() []string
// BindIdentity converts a caller-supplied login into the principal
// that is passed verbatim to LDAPConn.Bind. See "Writing your own
// adapter" below for the contract.
BindIdentity(login, baseDN string) (string, error)
}Some adapters additionally implement interfaces.PrimaryGroupResolver:
type PrimaryGroupResolver interface {
ResolvePrimaryGroupDN(ctx context.Context, user types.LDAPUser, ldapConn LDAPConn) (string, error)
}GetUserGroups checks for this interface via a type assertion. Only
MSADAdapter implements it (for AD's primaryGroupID resolution); the
others deliberately do not, because their schemas have no such concept
and the presence of the interface would cause GetUserGroups to try to
resolve a non-existent attribute.
PrimaryGroupResolver implementations MUST use only the provided
LDAPConn and MUST NOT call LDAPper methods — the resolver is
invoked while opMu is already held, so any re-entry into LDAPper
would deadlock.
The adapter is the only place LDAPper assumes anything about schema. See Shipped adapters for the four adapters that ship with the library.
Every search request is built by a single helper that applies
connection-level SearchLimits:
type SearchLimits struct {
SizeLimit int // 0 = server default
TimeLimit int // seconds; 0 = server default
MaxPages int // 0 = no page budget
MaxEntries int // 0 = no entry budget
RequirePaging bool // true = reject paged results without paging control
}conn, _ := ldapper.NewLDAPper(
ctx, hosts, 636, true, false, tlsCfg, adapter,
ldapper.WithSearchLimits(ldapper.SearchLimits{
SizeLimit: 500,
TimeLimit: 30,
RequirePaging: true,
}),
)
// Later, temporarily raise limits for one heavy pass:
old := conn.GetSearchLimits()
conn.SetSearchLimits(ldapper.SearchLimits{TimeLimit: 120})
tree, _ := conn.GetCatalogTree(ctx, "DC=corp,DC=example,DC=com", 0)
conn.SetSearchLimits(old)Field semantics:
SizeLimit— server-enforced total across the entire paged operation (RFC 2696 semantics).0means "no client-side size limit; use the server default".TimeLimit— server-side time limit in seconds.0means "no client-side time limit; use the server default".MaxPages— client-side page budget. When exceeded,searchPagedreturnsErrPartialResulttogether with the entries collected so far.MaxEntries— client-side entry budget. ComplementsSizeLimit: it works even against servers that ignoreSizeLimit. Truncates the slice and returnsErrPartialResult.RequirePaging— whentrue, a server that ignores the paging control is treated as a protocol violation andErrPartialResultis returned. Defaults totrueinDefaultSearchLimits(). Enable this when completeness matters (e.g. security decisions based on "user belongs to ALL groups").
Negative values are clamped to 0. SetSearchLimits takes opMu, so
it is safe to call concurrently with searches — but the value change
takes effect on the next request.
NB: WithSearchLimits replaces the entire struct. Fields
omitted from the argument get Go zero values, not the values from
DefaultSearchLimits(). In particular:
TimeLimit: 0means "no server-side time limit" (not 30 seconds);RequirePaging: falsemeans "silently accept missing paging control" (nottrue).
Use the read-modify-write pattern with DefaultSearchLimits() if you
want to keep the defaults for a subset of fields:
limits := ldapper.DefaultSearchLimits()
limits.SizeLimit = 100
conn, _ := ldapper.NewLDAPper(..., ldapper.WithSearchLimits(limits))conn, _ := ldapper.NewLDAPper(
ctx, hosts, 389, false, true, nil, adapter,
ldapper.WithDialTimeout(5*time.Second),
)
// At runtime:
conn.SetDialTimeout(10 * time.Second)
fmt.Println(conn.GetDialTimeout()) // 10s- After construction:
dialTimeout == constants.DefaultDialTimeout(10s). This is the actual default, not 0. - Explicitly setting it to
0(viaWithDialTimeout(0)orSetDialTimeout(0)) means "no explicit timeout; use Go'snet.Dialerdefault". - Negative values are clamped to
0. - The timeout is applied as
net.Dialer.Timeoutduring the initial dial and re-applied during theStartTLShandshake (so the handshake cannot block indefinitely). After the handshake,SetTimeout(0)resets the connection so subsequent operations are not capped by the dial timeout. ReconnectandEnsureAliveuse the same dial timeout.
OperationTimeouts are client-side timeouts applied around
individual Bind and Search calls. They complement — but do not
replace — SearchLimits.TimeLimit, which is a server-side limit.
type OperationTimeouts struct {
Bind time.Duration
Search time.Duration
}conn, _ := ldapper.NewLDAPper(
ctx, hosts, 389, false, true, nil, adapter,
ldapper.WithOperationTimeout(ldapper.OperationTimeouts{
Bind: 5 * time.Second,
Search: 30 * time.Second,
}),
)- Zero value disables the corresponding timeout (
Bind: 0, Search: 0is the default — backwards compatible). - Negative durations are clamped to zero.
- The timeout is applied via
LDAPConn.SetTimeout(which is part of theLDAPConninterface contract — every implementation, including mocks, must support it). After the operation, the timeout is reset to zero so it does not cap subsequent operations. - In addition,
withCtx/withCtxResultrespect the caller's context deadline: the effective timeout ismin(opTimeout, ctx.Deadline - now).
| Adapter | Directory | Notes |
|---|---|---|
adapters.MSADAdapter |
Microsoft Active Directory | Uses objectCategory-based filters; recursive group search via the LDAP_MATCHING_RULE_IN_CHAIN OID (1.2.840.113556.1.4.1941); prefers thumbnailPhoto over jpegPhoto; implements PrimaryGroupResolver to resolve primaryGroupID into a group DN. |
adapters.OPENLDAPAdapter |
OpenLDAP (and most RFC-4519 dialects) | Handles three group models — groupOfNames (via member), groupOfUniqueNames (via uniqueMember), and posixGroup (via memberUid). User search keyed by uid or entryDN. Short-form bind (alice) is expanded to uid=<login>,<baseDN>. |
adapters.ALDPROAdapter |
ALD Pro | Recognizes rbta-org-unit alongside organizationalUnit; department mapped from rbtadp; two-branch group filter — member/memberIndirect (DN) for groupOfNames/ipaUserGroup/posixGroup, and memberUid (uid) for posixGroup. Bind is pass-through (UPN/DN). |
adapters.FREEIPAAdapter |
FreeIPA | Recognizes nsContainer alongside organizationalUnit; department is intentionally not mapped; group lookup matches DN-valued member/memberIndirect and uid-valued memberUid. Short-form bind is expanded to uid=<login>,cn=users,cn=accounts,<baseDN>. |
MSADAdapter—GetUsermatches onsAMAccountName/userPrincipalName/distinguishedName. The value passed toNewAuthenticatedLDAPpermust therefore be a valid AD principal (UPN, sAMAccountName, or DN). Group search usesLDAP_MATCHING_RULE_IN_CHAIN, which Samba 4 AD DC and Microsoft AD both support — OpenLDAP and FreeIPA do not.OPENLDAPAdapter— the user filter uses the indexed placeholder%[1]sso the same argument is substituted into bothuid=andentryDN=, allowing lookup by either short name or full DN. The user-groups filter is a union of three branches:with args(|(&(objectClass=groupOfNames)(member=%[1]s)) (&(objectClass=groupOfUniqueNames)(uniqueMember=%[1]s)) (&(objectClass=posixGroup)(memberUid=%[2]s)))[user.DN, user.uid].ALDPROAdapter— the user-groups filter:with args(|(&(|(objectClass=groupOfNames)(objectClass=ipaUserGroup)(objectClass=posixGroup)) (|(member=%[1]s)(memberIndirect=%[1]s))) (&(objectClass=posixGroup)(memberUid=%[2]s)))[user.DN, user.uid].FREEIPAAdapter— the user-groups filter uses the same shape as ALDPro (two positional args, DN and uid).BindIdentity:MSAD/ALDPro— pass-through. The server performs the mapping.OPENLDAP— short-form login is expanded touid=<login>,<baseDN>.FreeIPA— short-form login is expanded touid=<login>,cn=users,cn=accounts,<baseDN>(the chain is built deepest-first:accounts→users→uid, so the final DN isuid=alice,cn=users,cn=accounts,<baseDN>).
GetNormalizedOUInfoFromEntry.Name— all shipped adapters deriveNamefrom the first RDN ofentry.DN(via the shared helperouNameFromDN), not from thename/ouattribute. Malformed DNs fall back to the whole string instead of crashing.GetNormalizedOUInfoFromEntry.OU— MSAD setsOUto the full entry DN; the other three adapters read theouattribute, withcnfallback for ALDPro'srbta-org-unitand FreeIPA'snsContainer.
All four adapters are stateless and safe to reuse across connections.
DistinguishedName is always taken from the LDAPUser.DN struct field,
never from an attribute. Every adapter reads the remaining fields from LDAP
attributes; — means "hardcoded to empty" (attribute not read even if
present).
UserNormalizedInfo field |
MSAD | OpenLDAP | ALD Pro | FreeIPA |
|---|---|---|---|---|
DistinguishedName |
struct DN |
struct DN |
struct DN |
struct DN |
CommonName |
cn |
cn |
cn |
cn |
Mobile |
mobile |
mobile |
mobile |
mobile |
Phone |
telephoneNumber |
telephoneNumber |
telephoneNumber |
telephoneNumber |
Mail |
mail |
mail |
mail |
mail |
Department |
department |
departmentNumber |
rbtadp |
— |
Title |
title |
title |
title |
title |
Manager |
manager |
manager |
manager |
manager |
Photo |
thumbnailPhoto → jpegPhoto |
jpegPhoto |
jpegPhoto |
jpegPhoto |
Company |
company |
o |
o |
o |
Country |
co |
— | co |
co |
Address |
streetAddress |
postalAddress |
streetAddress |
street |
City |
l |
— | l |
l |
Index |
postalCode |
— | postalCode |
postalCode |
Key differences to keep in mind:
Department— four different sources (department,departmentNumber,rbtadp, or none). FreeIPA simply does not map it.Address— MSAD and ALD Pro usestreetAddress, OpenLDAP usespostalAddress, FreeIPA usesstreet.Company— MSAD uses thecompanyattribute, the other three useo.Photo— only MSAD falls back fromthumbnailPhototojpegPhoto; the other three readjpegPhotoonly and do not consultthumbnailPhoto.Country/City/Index— OpenLDAP hardcodes these to empty regardless of the entry's attributes.
// Bind as `username` and store the credentials + base DN for later use.
err := conn.BindConn(ctx, username, password, baseDN)
// Verify a user's password against an already-authenticated connection,
// then rebind back to the service account.
user, err := conn.CheckUserCredentials(ctx, username, password)BindConn:
- Stores username, password and baseDN on the connection.
- Performs the bind first, then probes baseDN with a base-object search
to confirm the tree is reachable under the bound identity. No post-bind
user lookup is performed, and no
*types.LDAPUseris returned — callGetUserafterwards if you need the user entry. If the bind fails, the base-DN probe is never issued and the error isErrInvalidCredentials(testable witherrors.Is). - On success, caches
isAuthenticated=true,bindUser,bindPassword,bindDN.bindUseris the value returned byBindIdentity, not the raw login — that's whatReconnectreplays. - On any failure, closes the connection and invalidates the cached credentials (RFC 4513 §4 — a failed Bind transitions the session to anonymous; a successful Bind whose base-DN probe failed leaves the session identified as an unverified principal).
CheckUserCredentials:
- Requires
isAuthenticated == true(i.e.BindConnorNewAuthenticatedLDAPperwas called) and a live connection. Otherwise returnsErrNotAuthenticatedorErrClosedrespectively. - Uses the stored
bindDNas the base DN for the internal user lookup — callers do not repeat it. - User enumeration protection: a missing user is returned as
ErrInvalidCredentials, indistinguishable from a wrong password. - Returns
niluser on any failure — a returned*LDAPUseralways implies a successful verification. Do not checkuser != nilalone as an auth signal. - Restores the service bind via a deferred rebind. If the rebind itself
fails,
isAuthenticatedis cleared and the error surfaces asErrNotAuthenticated(or is appended to the primary error).
All bind-path errors go through classifySensitiveBindError, which never
echoes raw server text — passwords cannot leak into logs.
// Escape hatch: build any *ldap.SearchRequest yourself. No DN validation,
// no paging, no search-limit overrides — the caller owns semantics.
res, err := conn.CustomSearch(ctx, &ldap.SearchRequest{...})
// Same, with automatic paging applied on top of the request's controls.
// Base DN is validated; result is the concatenation of all pages.
entries, err := conn.CustomSearchPaged(ctx, &ldap.SearchRequest{...})
// Fetch a single user by the adapter's user filter template.
user, err := conn.GetUser(ctx, "jdoe", baseDN)
// Fetch DNs of all groups the user belongs to.
groups, err := conn.GetUserGroups(ctx, "jdoe", baseDN)
// Fetch a single group by its cn.
group, err := conn.GetGroup(ctx, "admins", baseDN)
// Fetch a single OU by its ou attribute value (e.g. "Sales").
ou, err := conn.GetOU(ctx, "Sales", baseDN)
// Fetch every user under an OU DN (subtree search, adapter user filter).
users, err := conn.GetOUUsers(ctx, "OU=Staff,DC=corp,DC=example,DC=com")-
GetUser,GetGroup,GetOU— single-object lookups. Each usesSizeLimit=2internally to detect duplicates and returns:ErrNotFoundOrNotVisibleif zero entries match,ErrAmbiguousResultif more than one entry matches.
ErrNotFoundOrNotVisibleis returned (notErrNotFound) because LDAP cannot distinguish "the object does not exist" from "the object is hidden by ACL". Never infer "does not exist" from this error in security-sensitive paths. -
GetUserGroups— callsGetUserfirst, then applies the adapter'sSearchUserGroupsFilterTemplate/SearchUserGroupsFilterTemplateArgswith[]string{"1.1"}(OID for "no attributes"), so only DNs come back from the server. Returns group DNs (notcnvalues). If the adapter implementsPrimaryGroupResolver(only MSAD does), the primary group DN is appended to the result. -
GetOUUsers— takes a DN, not a shortouname. Uses the adapter'sUserObjectFilterandUserAttrsto list all users under the subtree, subject to paging. -
GetOU— takes the shortouvalue (e.g."Sales") plus a base DN. -
GetGroup— takes the shortcnvalue.
All of these are safe to call concurrently: opMu serializes the
underlying LDAP operations.
tree, err := conn.GetCatalogTree(ctx, "DC=corp,DC=example,DC=com", 2)- Performs one paged subtree search (
ControlPaging, page sizeconstants.DefaultCatalogSearchPageSize = 1000) and builds theOUtree on the client. - Filters out entries that are not strict descendants of
baseDN(vialdap.DN.AncestorOfFold) — misbehaving servers and cross-tree referrals cannot leak foreign entries into the result. - Skips malformed DNs returned by the server rather than aborting.
- Nodes whose parent is not in the fetched set (e.g. server-side
filtering) are promoted to roots of
tree.Tree. maxDepthtrims the resulting tree:0= no limit,1= roots only,2= roots + first level, etc. FormaxDepth == 1, the tree is fetched withScopeSingleLevel— a single round-trip, no full subtree read.- If the server returns
SizeLimitExceededafter at least one page, the partial tree is returned along withErrPartialResult— callers can choose to accept the partial result or bail out. - A walk error discards the partial tree — the returned
types.CatalogTree.Treeis always non-nil but empty on failure.
// Root-level OUs only (single-level scope, one round-trip):
roots, err := conn.ListSingleLevelOUs(ctx, "DC=corp,DC=example,DC=com")
// Direct children of an OU (same as ListSingleLevelOUs, but explicit):
children, err := conn.GetSubOUs(
ctx,
"OU=Staff,DC=corp,DC=example,DC=com",
ldap.ScopeSingleLevel,
)
// All descendants of an OU (subtree scope):
descendants, err := conn.GetSubOUs(
ctx,
"OU=Staff,DC=corp,DC=example,DC=com",
ldap.ScopeWholeSubtree,
)
// Depth-first walk without building a tree:
err := conn.WalkCatalogTree(ctx, baseDN, 0,
func(ctx context.Context, depth int, node *types.OUNormalizedInfo) error {
log.Printf("%*s%s", depth*2, "", node.Name)
return nil
})GetSubOUs accepts only ldap.ScopeSingleLevel and
ldap.ScopeWholeSubtree; ldap.ScopeBaseObject is rejected with
ErrInvalidArgument (it would always return an empty list). The base DN
itself is always excluded — "sub" means strict descendants.
if !conn.IsAlive(ctx) {
_ = conn.Reconnect(ctx)
}
// Or the combined form, suitable as a pre-flight check:
if err := conn.EnsureAlive(ctx); err != nil {
return err
}IsAlive(ctx)— checks the local connection state (IsClosing) and issues a cheap root-DSE search with hard-codedSizeLimit=1, TimeLimit=1. Intentionally independent of the user-configured search limits.Reconnect(ctx)— closes the current connection, dials any host inhostsagain, and — if the connection was authenticated — re-binds with the stored credentials. On rebind failure, the new connection is closed,isAuthenticatedis cleared, and subsequent operations returnErrCloseduntilBindConnis called again.bindDNis preserved, soCheckUserCredentialscontinues to work after a successful reconnect.EnsureAlive(ctx)—IsAlive()first; reconnect only when stale. AfterClose()the client is terminal:EnsureAlivereturnsErrClosedand does not attempt to reconnect. That is the documented contract — a closed client cannot be revived.Close()— idempotent, best-effort (swallowsCloseerrors). Setsclosed=truewhich is terminal.
All public functions return sentinel errors that can be tested with
errors.Is. Where sensible, the original *ldap.Error / net.Error is
preserved in the wrapped chain (errors.As still works).
| Sentinel | Meaning |
|---|---|
ErrInvalidArgument |
Config or argument validation failed (empty/invalid DN, empty username, invalid host, invalid port, useLDAPS && skipStartTLS, nil TLS config, nil adapter, …). |
ErrInvalidCredentials |
Bind rejected as bad credentials. Also returned by CheckUserCredentials when the user is missing (enumeration protection). |
ErrInvalidCredentialsOrUnknown |
Bind-path classifier could not map the LDAP result code to a specific sentinel. Treat as inconclusive, not as a rejection. |
ErrNotAuthenticated |
CheckUserCredentials called on a connection without a successful prior BindConn/NewAuthenticatedLDAPper, or the service rebind was lost. |
ErrUnavailable |
Directory unreachable (dial failure, LDAPResultUnavailable/Busy/ServerDown, transport errors). |
ErrTimeout |
Timeout / deadline exceeded (LDAPResultTimeout, LDAPResultTimeLimitExceeded, net.Error.Timeout, context.DeadlineExceeded). |
ErrClosed |
The connection is nil (never initialized, or Close() was called, or a failed reconnect left it nil). |
ErrNotFound |
Search produced a definitive "no such object" (LDAPResultNoSuchObject). Rare — subtree searches usually return zero entries instead. |
ErrNotFoundOrNotVisible |
Subtree search returned zero entries. The object may not exist, or may be hidden by ACL. Do not infer "does not exist". |
ErrAmbiguousResult |
Single-object lookup matched more than one entry. |
ErrPartialResult |
Paged search was interrupted by SizeLimitExceeded, budget exhaustion, or a missing paging control (when RequirePaging is true). The returned slice/tree is partial. |
ErrInsufficientAccess |
Bind path returned LDAPResultInsufficientAccessRights. Surfaced by classifySensitiveBindError. |
Bind-path failures never wrap the raw LDAP text — this prevents
credential leakage into logs when the server echoes back the submitted
password. All bind errors are mapped to bare sentinels by
classifySensitiveBindError.
BindConn can return ErrNotFoundOrNotVisible if baseDN is unreachable under the bound identity (for example, when the bind principal has no read access to the tree root). This is produced by the base-DN probe, not by a user lookup.
Non-bind failures use classifyLDAPError, which wraps both the sentinel
and the original error:
if errors.Is(err, ldapper.ErrNotFoundOrNotVisible) { /* ... */ }
var ldapErr *ldap.Error
if errors.As(err, &ldapErr) {
log.Printf("LDAP result code: %d", ldapErr.ResultCode)
}Implement interfaces.CatalogAdapter for your directory. The adapter is
stateless and safe to reuse across connections. Skeleton:
type MyAdapter struct{}
func (MyAdapter) GetNormalizedUserInfo(u types.LDAPUser) *types.UserNormalizedInfo {
return &types.UserNormalizedInfo{
DistinguishedName: u.DN,
CommonName: u.GetAttributeValue("cn"),
Mail: u.GetAttributeValue("mail"),
}
}
func (MyAdapter) GetNormalizedOUInfoFromEntry(e ldap.Entry) *types.OUNormalizedInfo {
return &types.OUNormalizedInfo{
DN: e.DN,
Name: ouNameFromDN(e.DN), // first RDN value
OU: e.GetAttributeValue("ou"),
Children: make([]*types.OUNormalizedInfo, 0),
}
}
func (MyAdapter) UserObjectFilter() string { return "(&(objectClass=inetOrgPerson))" }
func (MyAdapter) GroupObjectFilter() string { return "(objectClass=groupOfNames)" }
func (MyAdapter) OUObjectFilter() string { return "(objectClass=organizationalUnit)" }
func (MyAdapter) SearchUserFilterTemplate() string { return "(&(objectClass=inetOrgPerson)(uid=%[1]s))" }
func (MyAdapter) SearchGroupFilterTemplate() string { return "(&(objectClass=groupOfNames)(cn=%[1]s))" }
func (MyAdapter) SearchOUFilterTemplate() string { return "(&(objectClass=organizationalUnit)(ou=%[1]s))" }
func (MyAdapter) SearchUserGroupsFilterTemplate() string {
return "(&(objectClass=groupOfNames)(member=%[1]s))"
}
func (MyAdapter) SearchUserGroupsFilterTemplateArgs(u types.LDAPUser) []any {
return []any{u.DN}
}
func (MyAdapter) UserAttrs() []string { return []string{"uid", "cn", "mail"} }
func (MyAdapter) OUAttrs() []string { return []string{"ou", "description"} }
func (MyAdapter) GroupAttrs() []string { return []string{"cn", "member"} }
// BindIdentity returns the principal passed verbatim to LDAPConn.Bind.
func (MyAdapter) BindIdentity(login, baseDN string) (string, error) {
if login == "" {
return "", fmt.Errorf("empty login")
}
// Pass-through: server decides whether `login` is a valid principal.
return login, nil
}Rules of thumb:
- Return raw, unescaped values from
SearchUserGroupsFilterTemplateArgs. LDAPper runs each value throughldap.EscapeFilterbefore substitution. Same for the single argument that is passed intoSearchUserFilterTemplate,SearchGroupFilterTemplateandSearchOUFilterTemplate. - Count placeholders carefully.
SearchUserFilterTemplate,SearchGroupFilterTemplateandSearchOUFilterTemplatetake exactly one argument.SearchUserGroupsFilterTemplatemay take any number, butSearchUserGroupsFilterTemplateArgsmust return exactly that many values in the same order. - Group membership format:
- Schema stores DN-valued
member→ use(member=%[1]s)and return[user.DN]. - Schema stores uid-valued
memberUid→ use(memberUid=%[1]s)and return[user.GetAttributeValue("uid")]. - Both (like
OPENLDAPAdapter/ALDPROAdapter/FREEIPAAdapter) → use a union with two placeholders and return both values in order.
GetNormalizedOUInfoFromEntrymust return a non-nilChildrenslice —GetCatalogTreemay append to it.DistinguishedNameinGetNormalizedUserInfomust come fromldapUser.DN(the struct field), not from an LDAP attribute.BindIdentitymust return something that the server accepts as a Simple Bind principal. If the adapter accepts both DN and short-form logins,SearchUserFilterTemplatemust match both (typically by unioning the schema attribute withentryDN=). SeeOPENLDAPAdapter/FREEIPAAdapterfor examples.PrimaryGroupResolver— implement only if your schema has a real concept of "primary group" that is not covered by ordinary group membership (like AD'sprimaryGroupID). The resolver MUST use only the providedLDAPConnand MUST NOT callLDAPpermethods — the lock is already held.
See adapters/msad_adapter.go, adapters/openldap_adapter.go,
adapters/aldpro_adapter.go and adapters/freeipa_adapter.go for
complete, production-quality examples.
Defaults live in constants/constants.go:
| Constant | Default | Meaning |
|---|---|---|
LDAPURITemplate |
"ldap://%s:%d" |
Used for plain / StartTLS dials |
LDAPSURITemplate |
"ldaps://%s:%d" |
Used for LDAPS dials |
DefaultSearchSizeLimit |
0 |
0 = no client-side size limit |
DefaultSearchTimeLimit |
30 |
Seconds |
DefaultMaxHostsLength |
50 |
Upper bound on the hosts slice |
DefaultCatalogSearchPageSize |
1000 |
Paging control size for paged searches |
DefaultDialTimeout |
10s |
Initial dial / StartTLS-handshake timeout |
DefaultStartTLSTimeout |
30s |
Used when no explicit dial timeout is set |
Options (type Option func(*LDAPper)):
| Option | Effect |
|---|---|
WithSearchLimits(SearchLimits) |
Replace connection-level search limits (whole struct) |
WithDialTimeout(d) |
Override dial / StartTLS-handshake timeout |
WithOperationTimeout(OperationTimeouts) |
Override client-side Bind/Search timeouts |
SetSearchLimits(SearchLimits) |
Runtime replacement of search limits |
GetSearchLimits() |
Read current search limits |
SetDialTimeout(d) |
Runtime override of dial timeout |
GetDialTimeout() |
Read current dial timeout |
SetOperationTimeout(OperationTimeouts) |
Runtime override of operation timeouts |
GetOperationTimeout() |
Read current operation timeouts |
Negative values are clamped to 0 ("no limit"/"Go default"). The zero
value of OperationTimeouts disables the client-side timeout, so it is
backwards compatible with code that does not configure it.
With* options are applied by NewLDAPper before the object is
returned to the caller; there is no concurrent access at that point.
Set*/Get* take opMu, so they are safe to call concurrently with
search operations.
The package is designed for testability at every layer.
Unit tests run without any network access and cover:
- Input validation —
validators_test.go: DN / base DN / host / port / TLS-config / option validation,formatHostIPv4-vs-IPv6. - Core helpers —
helpers_test.go:attrsToMap,escapeFilterArgs,parentDNOf(including multi-valued RDN preservation),canonicalDN(case folding, RDN-order significance, escaped-comma round-trip, malformed fallback). - Errors —
ldapper_errors_test.go:classifyLDAPError,classifySensitiveBindError(including the "no secret leak" regression test),isTimeoutError,isTransportError. - Options & limits —
ldapper_options_test.go:With/Set/GetSearchLimits,With/Set/GetDialTimeout,With/Set/GetOperationTimeout, clamping,-raceguards. - Search primitives —
ldapper_search_test.go:searchSingleEntryLocked,searchPaged(single page, multiple pages,MaxPages/MaxEntriesbudgets,RequirePagingon/off, repeated-cookie runaway,SizeLimitExceeded-preserves-entries, context-cancelled during/between pages, raw error propagation),CustomSearch,CustomSearchPaged. - Connection —
ldapper_connection_test.go:withCtx/withCtxResult(deadline merging, watchdog, cancellation invalidating the connection),Close,IsAlive,EnsureAlive,Reconnect,dialAnyLocked. Tests that swap the package-leveldialConnseam are not parallel — the swap is process-wide. - Auth —
ldapper_auth_test.go:invalidateAuthLocked,restoreServiceBindLocked,bindConnLocked/verifyBaseDNLocked(success path, 0-entry base probe, transport failure),BindConn,CheckUserCredentials(user-not-found masking, restore-failure path, context-cancelled path). - User / group / OU ops —
ldapper_user_ops_test.go,ldapper_group_ops_test.go,ldapper_ou_ops_test.go. - Catalog tree —
ldapper_catalog_tree_discovery_test.go:WalkCatalogTree(DFS order, depth truncation, deduplication, callback errors, context cancellation),GetCatalogTree,GetSubOUs,ListSingleLevelOUs. - Constructors —
ldapper_test.go:NewLDAPper/NewAuthenticatedLDAPpervalidation and defaults, options, TLS clone, hosts copy, dial failure, auth-failure propagation. Note that the success path ofNewAuthenticatedLDAPperrequires a live connection and is covered by integration tests only. - Shipped adapters —
adapters/*_adapter_test.go: user-info mapping, OU mapping, filter templates compiled byldap.CompileFilter, args ordering, attribute lists, distinguishing-attribute guards, MSADResolvePrimaryGroupDNchild-domain regression, FreeIPABindIdentitychain order.
go test ./...
go test -race -count=20 ./...
go vet ./...Test helpers:
utils_test.go(packageldapper):newLDAPEntry(dn, attrs) *ldap.Entry— builds a*ldap.Entrywith bothValuesandByteValuespopulated soGetRawAttributeValueworks in tests.ldapper_connection_test.go:withDialConn(t, fn)— swaps the package-leveldialConnseam for the duration of a test and restores it viat.Cleanup.ldapper_auth_test.go:fakeAdapter— a configurableCatalogAdapterstub used by core tests; each field defaults to an empty value.newAuthTestLDAPper,markAuthenticatedhelpers.ldapper_search_test.go:newTestLDAPper,searchCookie,pagedResponse— paging helpers.ldapper_test.go:stubTLSCfg,connectStub.adapters/helpers_test.go(packageadapters):newLDAPUser(dn, attrs)— builds atypes.LDAPUserfixture with a non-nil attribute map.attrMap(map[string]string) map[string][]string— shorthand for single-valued attributes.newLDAPEntry(dn, attrs) ldap.Entry— value form, matches the signature ofGetNormalizedOUInfoFromEntry. Note that theldapperpackage has a pointer-returningnewLDAPEntryinutils_test.go— same name, different package, different form.requireValidFilter(t, filter)— fails the test ifldap.CompileFilterrejects the filter, with the raw text included in the diagnostic.
Tip: tests that swap
dialConnmust not callt.Parallel(). The swap is process-wide; parallel tests would stomp on each other.withDialConnprovides the pattern.
End-to-end tests spin up real directories via
testcontainers-go and require a
running Docker daemon. Each suite lives in a separate file behind its own
build tag, because Go allows only one TestMain per package — the
tags keep the suites from compiling together.
| File | Build tag | Container |
|---|---|---|
openldap_integration_test.go |
integration_openldap |
osixia/openldap:1.5.0 |
msad_integration_test.go |
integration_msad |
nowszy/samba-ad-dc (Samba 4 AD) |
freeipa_integration_test.go |
integration_freeipa |
freeipa/freeipa-server:rocky-9 |
aldpro_integration_test.go |
integration_aldpro |
osixia/openldap + ALDPro schema |
Run one suite at a time:
go test -tags=integration_openldap ./...
go test -tags=integration_msad ./...
go test -tags=integration_freeipa ./...
go test -tags=integration_aldpro ./...What each suite exercises:
- OpenLDAP — full end-to-end coverage of the public API against
OPENLDAPAdapter; both branches of themember/memberUidgroup filter are positively asserted, plusentryDN-fallback lookups and duplicate detection (ErrAmbiguousResult). Breaks a user's password via direct LDAP Modify and verifies thatReconnecton the stale cached password fails and leaves the client unusable (ErrClosed). - MS AD — real Samba 4 AD DC, exercising the AD-only
LDAP_MATCHING_RULE_IN_CHAINgroup expansion and thePrimaryGroupResolverpath (Domain Users must appear inGetUserGroupsoutput). Gracefully skips if the container cannot be started. - FreeIPA — real FreeIPA container, verifying that
ipa group-add-memberpopulates DN-valuedmember/memberIndirect, and cross-checkingGetUserGroupsagainst an independentCustomSearchwith the same filter. Gracefully skips if the container cannot be started.Reconnect-rebind-failure is intentionallyt.Skip-ed here (FreeIPA password rotation is not scriptable non-interactively); the equivalent path is covered by the OpenLDAP suite. - ALDPro — stock OpenLDAP augmented with a custom schema mirroring
the ALDPro-specific objectClass
rbta-org-unitandrbtadpattribute. Therbta-org-unitclass is cn-named (SUP top MUST cn, it does not inherit fromorganizationalUnit), which is what exercises theou → cnfallback inGetNormalizedOUInfoFromEntryand thecn=arm ofSearchOUFilterTemplate. The suite also rewrites the defaultolcAccessthroughcn=configright after startup:attrs=userPassword by anonymous auth(Simple Bind compares the hash as an anonymous identity — without this every non-root bind fails withLDAP Result Code 49) andto * by users read(without this every authenticated search returnsLDAP Result Code 32 "No Such Object"even though the data is present). Exercises both branches of the dual-branch group filter, and confirms thatOUObjectFiltermatches both standardorganizationalUnitand ALDPro-specificrbta-org-unitentries. Uses a dedicatedsvc-readerinetOrgPerson for authenticated binds because the osixia admin is asimpleSecurityObjectthatUserFilterdoes not match.
All integration tests assert error conditions with errors.Is(err, ldapper.Err*) against the sentinel contract — not against error text —
so message rewording does not break them.
Distributed under the terms specified in the LICENSE file
at the repository root. The file is the authoritative source — this
section is only a pointer.
Copyright © 2020–2026 NGR Softlab. All rights reserved.
Before adding LDAPper as a dependency, check the LICENSE file for the
exact terms (permission to use, redistribute, modify, sublicense,
commercial use, warranty disclaimers, etc.). If your use case is not
covered by the file (e.g. redistribution in a closed-source product,
SaaS deployment with non-standard terms), contact NGR Softlab for a
commercial agreement.
LDAPper itself is small; the bulk of the dependency tree comes from
testcontainers-go, and only the go-ldap/ldap/v3 dependency is pulled
in at build time for production code. Test-only dependencies do not
ship in binaries that import LDAPper.
| Dependency | Role | License |
|---|---|---|
github.com/go-ldap/ldap/v3 |
runtime | MIT |
github.com/stretchr/testify |
tests only | MIT |
github.com/testcontainers/testcontainers-go + indirect |
tests only | MIT (various) |
Transitive dependencies are listed in go.mod; their licenses are
declared in their respective LICENSE files. Run
go run github.com/google/go-licenses@latest report ./... for an
up-to-date, machine-generated inventory.