package lldap import ( "context" "crypto/tls" "fmt" "net/url" "strings" "github.com/go-ldap/ldap/v3" "keycape/internal/domain" "keycape/internal/validator" ) // LDAPConn is a minimal interface over an LDAP connection, enabling test injection. // Only the operations used by the adapter are included; no concrete LDAP types are // exposed through return values or parameters visible outside this package. type LDAPConn interface { Bind(username, password string) error Search(request *ldap.SearchRequest) (*ldap.SearchResult, error) Close() error } // LDAPAdapter implements domain.UserRepository using an LLDAP backend. // All LDAP types are confined to this package — the domain and server layers // are not aware of any LDAP-specific constructs. type LDAPAdapter struct { cfg Config dialFn func(addr string) (LDAPConn, error) } // New returns a production-ready LDAPAdapter that dials real LDAP connections. func New(cfg Config) *LDAPAdapter { return &LDAPAdapter{ cfg: cfg, dialFn: defaultDialFn(cfg), } } // NewForTest returns an LDAPAdapter with a custom dial function for test injection. // Production code should use New instead. func NewForTest(cfg Config, dialFn func(addr string) (LDAPConn, error)) *LDAPAdapter { return &LDAPAdapter{cfg: cfg, dialFn: dialFn} } // defaultDialFn returns a dial function that establishes a real LDAP connection. func defaultDialFn(cfg Config) func(addr string) (LDAPConn, error) { return func(addr string) (LDAPConn, error) { u, err := url.Parse(cfg.URL) if err != nil { return nil, fmt.Errorf("lldap: invalid URL %q: %w", cfg.URL, err) } if u.Scheme == "ldaps" { conn, err := ldap.DialTLS("tcp", addr, &tls.Config{ InsecureSkipVerify: cfg.TLSSkipVerify, //nolint:gosec // dev flag, documented }) if err != nil { return nil, fmt.Errorf("lldap: TLS dial %q: %w", addr, err) } return conn, nil } conn, err := ldap.Dial("tcp", addr) if err != nil { return nil, fmt.Errorf("lldap: dial %q: %w", addr, err) } return conn, nil } } // dial opens a new LDAP connection and performs the service-account bind. func (a *LDAPAdapter) dial() (LDAPConn, error) { u, err := url.Parse(a.cfg.URL) if err != nil { return nil, fmt.Errorf("lldap: invalid URL %q: %w", a.cfg.URL, err) } host := u.Host if host == "" { host = a.cfg.URL // fallback for bare addr passed in tests } conn, err := a.dialFn(host) if err != nil { return nil, err } if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPW); err != nil { _ = conn.Close() return nil, fmt.Errorf("lldap: service bind failed: %w", err) } return conn, nil } // --------------------------------------------------------------------------- // domain.UserRepository implementation // --------------------------------------------------------------------------- // LookupUser retrieves the canonical User for the given username. // Returns domain.ErrUserNotFound when no matching entry exists. // After mapping LDAP attributes the result is run through the canonical // LDAP schema validator; a validation failure is returned as an error. func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain.User, error) { conn, err := a.dial() if err != nil { return nil, err } defer conn.Close() filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)) req := ldap.NewSearchRequest( a.cfg.userBaseDN(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, filter, []string{"dn", "uid", "cn", "sn", "mail", "memberOf"}, nil, ) result, err := conn.Search(req) if err != nil { return nil, fmt.Errorf("lldap: search for user %q: %w", username, err) } if len(result.Entries) == 0 { return nil, domain.ErrUserNotFound } entry := result.Entries[0] user := mapEntryToUser(entry) // Runtime login should not fail because a live directory entry is missing // provisioning metadata such as cn/sn. Keep the warning visible for // diagnostics, but return the resolved user so token issuance can proceed. snap := validator.Snapshot{Users: []domain.User{user}} report := validator.Validate(snap, validator.ModeProvisioning) if !report.Passed { if user.LDAPAttributes == nil { user.LDAPAttributes = make(map[string]string) } user.LDAPAttributes["_validation_warning"] = validationSummary(report) } return &user, nil } // LookupGroups retrieves all groups the user (identified by their LDAP DN) belongs to. func (a *LDAPAdapter) LookupGroups(ctx context.Context, userDN string) ([]domain.Group, error) { conn, err := a.dial() if err != nil { return nil, err } defer conn.Close() // Search for groups that list the user as a member. filter := fmt.Sprintf("(member=%s)", ldap.EscapeFilter(userDN)) req := ldap.NewSearchRequest( a.cfg.groupBaseDN(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, filter, []string{"dn", "cn", "description"}, nil, ) result, err := conn.Search(req) if err != nil { return nil, fmt.Errorf("lldap: group search for DN %q: %w", userDN, err) } groups := make([]domain.Group, 0, len(result.Entries)) for _, entry := range result.Entries { groups = append(groups, domain.Group{ ID: entry.DN, Name: entry.GetAttributeValue("cn"), Description: entry.GetAttributeValue("description"), }) } return groups, nil } // ListUsers returns all user records from the LLDAP directory. // It performs an LDAP search with filter (objectClass=inetOrgPerson) to list every user, // then validates each against the canonical LDAP schema. func (a *LDAPAdapter) ListUsers(ctx context.Context) ([]domain.User, error) { conn, err := a.dial() if err != nil { return nil, err } defer conn.Close() req := ldap.NewSearchRequest( a.cfg.userBaseDN(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=inetOrgPerson)", []string{"dn", "uid", "cn", "sn", "mail", "memberOf"}, nil, ) result, err := conn.Search(req) if err != nil { return nil, fmt.Errorf("lldap: list users search: %w", err) } users := make([]domain.User, 0, len(result.Entries)) for _, entry := range result.Entries { user := mapEntryToUser(entry) snap := validator.Snapshot{Users: []domain.User{user}} report := validator.Validate(snap, validator.ModeProvisioning) if !report.Passed { // Non-fatal: return the user with a warning embedded in LDAPAttributes. if user.LDAPAttributes == nil { user.LDAPAttributes = make(map[string]string) } user.LDAPAttributes["_validation_warning"] = validationSummary(report) } users = append(users, user) } return users, nil } // ValidatePassword returns true when the username and password are valid. // It opens a second connection and attempts a user bind. Bind failure (wrong // credentials) returns false, nil. Infrastructure errors return false, err. func (a *LDAPAdapter) ValidatePassword(ctx context.Context, username, password string) (bool, error) { // First resolve the user DN. conn, err := a.dial() if err != nil { return false, err } filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)) req := ldap.NewSearchRequest( a.cfg.userBaseDN(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, filter, []string{"dn"}, nil, ) result, err := conn.Search(req) conn.Close() if err != nil { return false, fmt.Errorf("lldap: DN lookup for user %q: %w", username, err) } if len(result.Entries) == 0 { return false, nil } userDN := result.Entries[0].DN // Attempt a user bind with the provided password using a fresh connection. host := ldapHost(a.cfg.URL) userConn, err := a.dialFn(host) if err != nil { return false, err } defer userConn.Close() if err := userConn.Bind(userDN, password); err != nil { // Distinguish authentication failure from infrastructure error. if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { return false, nil } return false, fmt.Errorf("lldap: user bind for %q: %w", username, err) } return true, nil } // --------------------------------------------------------------------------- // Attribute mapping helpers (LDAP → canonical domain model). // --------------------------------------------------------------------------- // mapEntryToUser converts an LDAP entry to a canonical domain.User. // Attribute mapping per spec: // - uid → Username // - cn → DisplayName (sn as fallback) // - sn → DisplayName fallback if cn is empty // - mail → Email // - memberOf → Groups (DNs parsed to group names) // - dn → ID (stable identifier) func mapEntryToUser(entry *ldap.Entry) domain.User { displayName := entry.GetAttributeValue("cn") if displayName == "" { displayName = entry.GetAttributeValue("sn") } memberOfs := entry.GetAttributeValues("memberOf") groups := make([]string, 0, len(memberOfs)) for _, dn := range memberOfs { groups = append(groups, groupNameFromDN(dn)) } return domain.User{ ID: entry.DN, Username: entry.GetAttributeValue("uid"), DisplayName: displayName, Email: entry.GetAttributeValue("mail"), Groups: groups, Enabled: true, // LLDAP does not expose a disabled flag in base schema } } // groupNameFromDN extracts the cn value from an LDAP DN such as // "cn=admins,ou=groups,dc=netkingdom,dc=local" → "admins". // If parsing fails the full DN is returned unchanged. func groupNameFromDN(dn string) string { parts := strings.SplitN(dn, ",", 2) if len(parts) == 0 { return dn } kv := strings.SplitN(parts[0], "=", 2) if len(kv) == 2 { return kv[1] } return dn } // ldapHost extracts host:port from a URL string; falls back to the raw value. func ldapHost(rawURL string) string { u, err := url.Parse(rawURL) if err != nil || u.Host == "" { return rawURL } return u.Host } // validationSummary produces a short string summarising all failed rules. func validationSummary(r validator.Report) string { var msgs []string for _, rule := range r.Structural { if !rule.Passed { msgs = append(msgs, rule.Message) } } for _, rule := range r.Semantic { if !rule.Passed { msgs = append(msgs, rule.Message) } } return strings.Join(msgs, "; ") }