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 } // ListGroups returns every group in the LLDAP group subtree with its members // populated, independently of any user's membership. Export tooling needs this // to enumerate empty and unreferenced groups, which a (member=DN) search by // construction cannot see. func (a *LDAPAdapter) ListGroups(ctx context.Context) ([]domain.Group, error) { conn, err := a.dial() if err != nil { return nil, err } defer conn.Close() req := ldap.NewSearchRequest( a.cfg.groupBaseDN(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(|(objectClass=groupOfNames)(objectClass=groupOfUniqueNames))", []string{"dn", "cn", "description", "member", "uniqueMember"}, nil, ) result, err := conn.Search(req) if err != nil { return nil, fmt.Errorf("lldap: list groups search: %w", err) } groups := make([]domain.Group, 0, len(result.Entries)) for _, entry := range result.Entries { members := entry.GetAttributeValues("member") if len(members) == 0 { members = entry.GetAttributeValues("uniqueMember") } groups = append(groups, domain.Group{ ID: entry.DN, Name: entry.GetAttributeValue("cn"), Description: entry.GetAttributeValue("description"), Members: members, }) } 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)) } tenant, roles := identityEnvelopeFromGroups(groups) return domain.User{ ID: entry.DN, Username: entry.GetAttributeValue("uid"), DisplayName: displayName, Email: entry.GetAttributeValue("mail"), Groups: groups, Roles: roles, Tenant: tenant, Enabled: true, // LLDAP does not expose a disabled flag in base schema } } // identityEnvelopeFromGroups maps explicit tenant membership groups to the // coarse IAM Profile envelope. Fine-grained authorization remains flex-auth's // responsibility. Supported names are: // // tenant:::users // tenant:::admins // // Multiple tenant envelopes are deliberately ignored because an interactive // token must carry one unambiguous active tenant. func identityEnvelopeFromGroups(groups []string) (string, []string) { tenants := make(map[string]bool) admins := make(map[string]bool) platformOperator := false for _, group := range groups { switch { case group == "net-kingdom-admins": platformOperator = true case strings.HasPrefix(group, "tenant:") && strings.HasSuffix(group, ":users"): tenants[strings.TrimSuffix(group, ":users")] = true case strings.HasPrefix(group, "tenant:") && strings.HasSuffix(group, ":admins"): tenant := strings.TrimSuffix(group, ":admins") tenants[tenant] = true admins[tenant] = true } } // Preserve the established bootstrap/platform-root group as an explicit // platform control-plane identity. Tenant envelopes must never narrow or // ambiguously reinterpret platform authority. if platformOperator { return "tenant:platform", []string{"user", "platform-operator"} } if len(tenants) != 1 { return "", []string{} } var tenant string for candidate := range tenants { tenant = candidate } roles := []string{"user"} if admins[tenant] { roles = append(roles, "tenant-admin") } return tenant, roles } // 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, "; ") } // Compile-time checks: the adapter satisfies the directory contracts it claims. var ( _ domain.UserRepository = (*LDAPAdapter)(nil) _ domain.GroupLister = (*LDAPAdapter)(nil) ) // Ping reports whether the directory is reachable and the configured service // credentials still bind. It is the readiness probe for LLDAP (KEY-WP-0025). // // A bind rather than a bare dial: a rotated or revoked service password leaves // the port open and every lookup failing, which is exactly the state readiness // exists to catch. dial already binds, so this opens and closes one connection. func (a *LDAPAdapter) Ping(ctx context.Context) error { type dialResult struct { conn LDAPConn err error } // dial is synchronous and has no context, so race it against the caller's // deadline rather than letting a hung directory outlive the probe budget. results := make(chan dialResult, 1) go func() { conn, err := a.dial() results <- dialResult{conn, err} }() select { case <-ctx.Done(): go func() { if result := <-results; result.err == nil { result.conn.Close() } }() return ctx.Err() case result := <-results: if result.err != nil { return result.err } result.conn.Close() return nil } }