package service import ( "fmt" "strings" "time" "github.com/go-ldap/ldap/v3" "github.com/rs/zerolog" "github.com/gosec/gsc-ops-api/internal/client" "github.com/gosec/gsc-ops-api/internal/schema" "github.com/gosec/gsc-ops-api/pkg/types" ) // LDAPService handles FreeIPA user and group operations type LDAPService struct { client *client.LDAPClient baseDN string logger zerolog.Logger registry *schema.Registry } // NewLDAPService creates a new LDAP service func NewLDAPService(ldapClient *client.LDAPClient, baseDN string, logger zerolog.Logger, registry *schema.Registry) *LDAPService { return &LDAPService{ client: ldapClient, baseDN: baseDN, logger: logger.With().Str("service", "ldap").Logger(), registry: registry, } } func (s *LDAPService) userBaseDN() string { return "cn=users,cn=accounts," + s.baseDN } func (s *LDAPService) groupBaseDN() string { return "cn=groups,cn=accounts," + s.baseDN } func (s *LDAPService) userDN(uid string) string { return fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(uid), s.userBaseDN()) } func (s *LDAPService) groupDN(cn string) string { return fmt.Sprintf("cn=%s,%s", ldap.EscapeFilter(cn), s.groupBaseDN()) } // coreUserAttrs are the base LDAP attributes for user listing (no gsc* attrs) var coreUserAttrs = []string{ "uid", "givenName", "sn", "displayName", "mail", "telephoneNumber", "title", "nsAccountLock", "loginShell", "homeDirectory", "memberOf", } // userSearchAttrs returns core attrs plus all gsc* attrs for full user retrieval func (s *LDAPService) userSearchAttrs() []string { gscAttrs := s.registry.AllUserAttrs() attrs := make([]string, 0, len(coreUserAttrs)+len(gscAttrs)+1) attrs = append(attrs, coreUserAttrs...) attrs = append(attrs, "objectClass") attrs = append(attrs, gscAttrs...) return attrs } var groupAttrs = []string{ "cn", "description", "member", "gidNumber", } // ListUsers searches for users, optionally filtering by search string, // service objectClasses, and/or arbitrary LDAP attribute values. // // attrFilters maps raw LDAP attribute names to match values. Values may // contain LDAP wildcards (e.g. "*@example.com"). The attribute name itself // is sanitised to prevent filter injection. func (s *LDAPService) ListUsers(search string, limit int, serviceFilters []string, attrFilters map[string]string) ([]types.LDAPUser, error) { // Start with base object class filter parts := []string{"(objectClass=posixAccount)"} // Free-text search across core fields if search != "" { escaped := ldap.EscapeFilter(search) parts = append(parts, fmt.Sprintf("(|(uid=*%s*)(givenName=*%s*)(sn=*%s*)(mail=*%s*))", escaped, escaped, escaped, escaped)) } // Service objectClass filters for _, svc := range serviceFilters { oc := s.registry.UserOCForDomain(svc) if oc != "" { parts = append(parts, fmt.Sprintf("(objectClass=%s)", oc)) } } // Dynamic LDAP attribute filters // Collect extra attrs we need to request so the server evaluates the filter var extraAttrs []string for attr, val := range attrFilters { // Sanitise attribute name: only allow alphanumeric, dash, semicolon safe := true for _, ch := range attr { if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == ';') { safe = false break } } if !safe || attr == "" { continue } // Escape value but preserve * wildcards for substring matching. // Split on *, escape each segment, rejoin with *. segments := strings.Split(val, "*") for i, seg := range segments { segments[i] = ldap.EscapeFilter(seg) } escapedVal := strings.Join(segments, "*") parts = append(parts, fmt.Sprintf("(%s=%s)", attr, escapedVal)) extraAttrs = append(extraAttrs, attr) } // Build final filter var filter string if len(parts) == 1 { filter = parts[0] } else { filter = "(&" + strings.Join(parts, "") + ")" } // When service filters are present, fetch full gsc* attrs so the // response includes the services block (e.g. gscSID for chat). includeServices := len(serviceFilters) > 0 var attrs []string if includeServices { attrs = s.userSearchAttrs() if len(extraAttrs) > 0 { attrs = append(attrs, extraAttrs...) } } else { attrs = coreUserAttrs if len(extraAttrs) > 0 { attrs = make([]string, len(coreUserAttrs), len(coreUserAttrs)+len(extraAttrs)) copy(attrs, coreUserAttrs) attrs = append(attrs, extraAttrs...) } } entries, err := s.client.Search(s.userBaseDN(), filter, attrs, limit) if err != nil { return nil, err } users := make([]types.LDAPUser, 0, len(entries)) for _, entry := range entries { users = append(users, s.entryToUser(entry, includeServices)) } return users, nil } // GetUser gets a user by UID with full service attributes func (s *LDAPService) GetUser(uid string) (*types.LDAPUser, error) { filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid)) entry, err := s.client.SearchOne(s.userBaseDN(), filter, s.userSearchAttrs()) if err != nil { return nil, err } if entry == nil { return nil, nil } user := s.entryToUser(entry, true) return &user, nil } // GetUserServices returns only service attributes for a user func (s *LDAPService) GetUserServices(uid string, domain string) (map[string]map[string]interface{}, error) { filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid)) entry, err := s.client.SearchOne(s.userBaseDN(), filter, s.userSearchAttrs()) if err != nil { return nil, err } if entry == nil { return nil, nil } services := s.extractServices(entry) if domain != "" { filtered := make(map[string]map[string]interface{}) if svc, ok := services[domain]; ok { filtered[domain] = svc } return filtered, nil } return services, nil } // CreateUser creates a new FreeIPA user func (s *LDAPService) CreateUser(req *types.LDAPUserCreate) (*types.LDAPUser, error) { dn := s.userDN(req.UID) objectClasses := []string{"top", "person", "organizationalPerson", "inetOrgPerson", "posixAccount", "krbPrincipalAux", "ipaObject"} addReq := ldap.NewAddRequest(dn, nil) addReq.Attribute("uid", []string{req.UID}) addReq.Attribute("givenName", []string{req.FirstName}) addReq.Attribute("sn", []string{req.LastName}) addReq.Attribute("cn", []string{req.FirstName + " " + req.LastName}) addReq.Attribute("displayName", []string{req.FirstName + " " + req.LastName}) if req.Email != "" { addReq.Attribute("mail", []string{req.Email}) } if req.Phone != "" { addReq.Attribute("telephoneNumber", []string{req.Phone}) } if req.Title != "" { addReq.Attribute("title", []string{req.Title}) } shell := "/bin/bash" if req.Shell != "" { shell = req.Shell } addReq.Attribute("loginShell", []string{shell}) addReq.Attribute("homeDirectory", []string{"/home/" + req.UID}) // Process service attributes if len(req.Services) > 0 { svcOCs, svcAttrs, err := s.resolveServices(req.Services) if err != nil { return nil, fmt.Errorf("invalid services: %w", err) } objectClasses = append(objectClasses, svcOCs...) for attrName, vals := range svcAttrs { addReq.Attribute(attrName, vals) } // Add audit timestamps now := time.Now().UTC().Format("20060102150405Z") addReq.Attribute("gscCreatedAt", []string{now}) addReq.Attribute("gscModifiedAt", []string{now}) } addReq.Attribute("objectClass", objectClasses) if err := s.client.Add(addReq); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } // Set password if provided if req.Password != "" { if err := s.client.PasswordModify(dn, req.Password); err != nil { s.logger.Warn().Err(err).Str("uid", req.UID).Msg("user created but password set failed") } } return s.GetUser(req.UID) } // UpdateUser updates a user's attributes func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.LDAPUser, error) { dn := s.userDN(uid) modReq := ldap.NewModifyRequest(dn, nil) modified := false if req.FirstName != nil { modReq.Replace("givenName", []string{*req.FirstName}) modified = true } if req.LastName != nil { modReq.Replace("sn", []string{*req.LastName}) modified = true } if req.FirstName != nil || req.LastName != nil { // Update display name and cn first, last := "", "" if req.FirstName != nil { first = *req.FirstName } if req.LastName != nil { last = *req.LastName } if first != "" || last != "" { display := strings.TrimSpace(first + " " + last) if display != "" { modReq.Replace("displayName", []string{display}) modReq.Replace("cn", []string{display}) } } } if req.Email != nil { modReq.Replace("mail", []string{*req.Email}) modified = true } if req.Phone != nil { modReq.Replace("telephoneNumber", []string{*req.Phone}) modified = true } if req.Title != nil { modReq.Replace("title", []string{*req.Title}) modified = true } if req.Shell != nil { modReq.Replace("loginShell", []string{*req.Shell}) modified = true } if req.Disabled != nil { if *req.Disabled { modReq.Replace("nsAccountLock", []string{"TRUE"}) } else { modReq.Replace("nsAccountLock", []string{"FALSE"}) } modified = true } // Process service attributes if len(req.Services) > 0 { svcOCs, svcAttrs, err := s.resolveServices(req.Services) if err != nil { return nil, fmt.Errorf("invalid services: %w", err) } // Fetch current objectClasses to determine which to add currentOCs, err := s.getCurrentObjectClasses(uid) if err != nil { return nil, fmt.Errorf("failed to read current objectClasses: %w", err) } currentOCSet := make(map[string]bool, len(currentOCs)) for _, oc := range currentOCs { currentOCSet[oc] = true } newOCs := make([]string, 0) for _, oc := range svcOCs { if !currentOCSet[oc] { newOCs = append(newOCs, oc) } } if len(newOCs) > 0 { modReq.Add("objectClass", newOCs) } for attrName, vals := range svcAttrs { modReq.Replace(attrName, vals) } // Update audit timestamp now := time.Now().UTC().Format("20060102150405Z") modReq.Replace("gscModifiedAt", []string{now}) modified = true } if !modified { return s.GetUser(uid) } if err := s.client.Modify(modReq); err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } return s.GetUser(uid) } // DisableUser disables a user account func (s *LDAPService) DisableUser(uid string) error { dn := s.userDN(uid) modReq := ldap.NewModifyRequest(dn, nil) modReq.Replace("nsAccountLock", []string{"TRUE"}) return s.client.Modify(modReq) } // ResetPassword resets a user's password func (s *LDAPService) ResetPassword(uid, newPassword string) error { dn := s.userDN(uid) return s.client.PasswordModify(dn, newPassword) } // GetUserGroups lists groups a user belongs to func (s *LDAPService) GetUserGroups(uid string) ([]string, error) { filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid)) entry, err := s.client.SearchOne(s.userBaseDN(), filter, []string{"memberOf"}) if err != nil { return nil, err } if entry == nil { return nil, nil } memberOf := entry.GetAttributeValues("memberOf") groups := make([]string, 0, len(memberOf)) for _, dn := range memberOf { // Extract cn from DN parts := strings.Split(dn, ",") if len(parts) > 0 && strings.HasPrefix(parts[0], "cn=") { groups = append(groups, strings.TrimPrefix(parts[0], "cn=")) } } return groups, nil } // ListGroups searches for groups func (s *LDAPService) ListGroups(search string, limit int) ([]types.LDAPGroup, error) { filter := "(objectClass=groupOfNames)" if search != "" { escaped := ldap.EscapeFilter(search) filter = fmt.Sprintf("(&(objectClass=groupOfNames)(|(cn=*%s*)(description=*%s*)))", escaped, escaped) } entries, err := s.client.Search(s.groupBaseDN(), filter, groupAttrs, limit) if err != nil { return nil, err } groups := make([]types.LDAPGroup, 0, len(entries)) for _, entry := range entries { groups = append(groups, s.entryToGroup(entry)) } return groups, nil } // GetGroup gets a group by CN func (s *LDAPService) GetGroup(cn string) (*types.LDAPGroup, error) { filter := fmt.Sprintf("(&(objectClass=groupOfNames)(cn=%s))", ldap.EscapeFilter(cn)) entry, err := s.client.SearchOne(s.groupBaseDN(), filter, groupAttrs) if err != nil { return nil, err } if entry == nil { return nil, nil } group := s.entryToGroup(entry) return &group, nil } // CreateGroup creates a new group func (s *LDAPService) CreateGroup(req *types.LDAPGroupCreate) (*types.LDAPGroup, error) { dn := s.groupDN(req.CN) addReq := ldap.NewAddRequest(dn, nil) addReq.Attribute("objectClass", []string{"top", "groupOfNames", "posixGroup", "ipaObject"}) addReq.Attribute("cn", []string{req.CN}) if req.Description != "" { addReq.Attribute("description", []string{req.Description}) } if err := s.client.Add(addReq); err != nil { return nil, fmt.Errorf("failed to create group: %w", err) } return s.GetGroup(req.CN) } // UpdateGroup updates a group's attributes func (s *LDAPService) UpdateGroup(cn string, req *types.LDAPGroupUpdate) (*types.LDAPGroup, error) { dn := s.groupDN(cn) modReq := ldap.NewModifyRequest(dn, nil) if req.Description != nil { modReq.Replace("description", []string{*req.Description}) } if err := s.client.Modify(modReq); err != nil { return nil, fmt.Errorf("failed to update group: %w", err) } return s.GetGroup(cn) } // DeleteGroup deletes a group func (s *LDAPService) DeleteGroup(cn string) error { dn := s.groupDN(cn) return s.client.Delete(dn) } // GetGroupMembers lists members of a group func (s *LDAPService) GetGroupMembers(cn string) ([]string, error) { group, err := s.GetGroup(cn) if err != nil { return nil, err } if group == nil { return nil, nil } return group.Members, nil } // AddGroupMembers adds members to a group func (s *LDAPService) AddGroupMembers(cn string, uids []string) error { dn := s.groupDN(cn) modReq := ldap.NewModifyRequest(dn, nil) for _, uid := range uids { memberDN := s.userDN(uid) modReq.Add("member", []string{memberDN}) } return s.client.Modify(modReq) } // RemoveGroupMember removes a member from a group func (s *LDAPService) RemoveGroupMember(cn, uid string) error { dn := s.groupDN(cn) memberDN := s.userDN(uid) modReq := ldap.NewModifyRequest(dn, nil) modReq.Delete("member", []string{memberDN}) return s.client.Modify(modReq) } func (s *LDAPService) entryToUser(entry *ldap.Entry, includeServices bool) types.LDAPUser { memberOf := entry.GetAttributeValues("memberOf") groups := make([]string, 0, len(memberOf)) for _, dn := range memberOf { parts := strings.Split(dn, ",") if len(parts) > 0 && strings.HasPrefix(parts[0], "cn=") { groups = append(groups, strings.TrimPrefix(parts[0], "cn=")) } } disabled := strings.EqualFold(entry.GetAttributeValue("nsAccountLock"), "TRUE") user := types.LDAPUser{ UID: entry.GetAttributeValue("uid"), FirstName: entry.GetAttributeValue("givenName"), LastName: entry.GetAttributeValue("sn"), DisplayName: entry.GetAttributeValue("displayName"), Email: entry.GetAttributeValue("mail"), Phone: entry.GetAttributeValue("telephoneNumber"), Title: entry.GetAttributeValue("title"), Disabled: disabled, Groups: groups, Shell: entry.GetAttributeValue("loginShell"), HomeDir: entry.GetAttributeValue("homeDirectory"), } if includeServices { user.ObjectClasses = entry.GetAttributeValues("objectClass") user.Services = s.extractServices(entry) } return user } // extractServices extracts gsc* attributes from an LDAP entry, grouped by domain func (s *LDAPService) extractServices(entry *ldap.Entry) map[string]map[string]interface{} { services := make(map[string]map[string]interface{}) for _, attr := range entry.Attributes { def := s.registry.GetAttr(attr.Name) if def == nil { continue } val := s.registry.LDAPValueToGo(def, attr.Values) if val == nil { continue } if services[def.Domain] == nil { services[def.Domain] = make(map[string]interface{}) } services[def.Domain][def.JSONName] = val } return services } // resolveServices validates and converts service attributes to LDAP format. // Returns: required objectClasses, LDAP attribute map, or error. func (s *LDAPService) resolveServices(services map[string]map[string]interface{}) ([]string, map[string][]string, error) { ldapAttrs := make(map[string][]string) usedLDAPNames := make([]string, 0) for domain, attrs := range services { domainDefs := s.registry.AttrsForDomain(domain) if domainDefs == nil { return nil, nil, fmt.Errorf("unknown service domain: %s", domain) } for jsonName, value := range attrs { def := s.registry.GetAttrByJSON(domain, jsonName) if def == nil { return nil, nil, fmt.Errorf("unknown attribute %s in domain %s", jsonName, domain) } if def.ReadOnly { continue // skip read-only attrs silently } vals, err := s.registry.GoValueToLDAP(def, value) if err != nil { return nil, nil, fmt.Errorf("attribute %s.%s: %w", domain, jsonName, err) } if vals != nil { ldapAttrs[def.LDAPName] = vals usedLDAPNames = append(usedLDAPNames, def.LDAPName) } } } // Determine required objectClasses ocs := s.registry.RequiredOCsForAttrs(usedLDAPNames) // Validate that all MUST attrs for each OC are provided for _, ocName := range ocs { oc := s.registry.GetObjectClass(ocName) if oc == nil { continue } for _, must := range oc.Must { if _, ok := ldapAttrs[must]; !ok { return nil, nil, fmt.Errorf("objectClass %s requires attribute %s", ocName, must) } } } return ocs, ldapAttrs, nil } // getCurrentObjectClasses fetches the current objectClasses of a user entry func (s *LDAPService) getCurrentObjectClasses(uid string) ([]string, error) { filter := fmt.Sprintf("(&(objectClass=posixAccount)(uid=%s))", ldap.EscapeFilter(uid)) entry, err := s.client.SearchOne(s.userBaseDN(), filter, []string{"objectClass"}) if err != nil { return nil, err } if entry == nil { return nil, fmt.Errorf("user not found: %s", uid) } return entry.GetAttributeValues("objectClass"), nil } func (s *LDAPService) entryToGroup(entry *ldap.Entry) types.LDAPGroup { members := entry.GetAttributeValues("member") uids := make([]string, 0, len(members)) for _, dn := range members { parts := strings.Split(dn, ",") if len(parts) > 0 && strings.HasPrefix(parts[0], "uid=") { uids = append(uids, strings.TrimPrefix(parts[0], "uid=")) } } return types.LDAPGroup{ CN: entry.GetAttributeValue("cn"), Description: entry.GetAttributeValue("description"), Members: uids, GIDNumber: entry.GetAttributeValue("gidNumber"), } }