package service import ( "fmt" "strings" "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. Reads go over direct // LDAP (fast); MUTATIONS go through the FreeIPA management API (ipa) so the IPA // framework assigns uidNumber/gidNumber/ipaUniqueID, sets correct objectClasses // and enforces its own logic — a raw LDAP add cannot do this. type LDAPService struct { client *client.LDAPClient ipa *client.FreeIPAClient baseDN string logger zerolog.Logger registry *schema.Registry } // NewLDAPService creates a new LDAP service. ipa may be nil (writes then error // cleanly instead of attempting a raw LDAP mutation). func NewLDAPService(ldapClient *client.LDAPClient, ipa *client.FreeIPAClient, baseDN string, logger zerolog.Logger, registry *schema.Registry) *LDAPService { return &LDAPService{ client: ldapClient, ipa: ipa, baseDN: baseDN, logger: logger.With().Str("service", "ldap").Logger(), registry: registry, } } // requireIPA guards mutation methods that need the FreeIPA API. func (s *LDAPService) requireIPA() error { if s.ipa == nil { return fmt.Errorf("FreeIPA management API is not configured; writes are disabled") } return nil } // ipaServiceOptions converts a services map into IPA user_add/user_mod // addattr (objectclass=…) and setattr (attr=value) option slices. func (s *LDAPService) ipaServiceOptions(services map[string]map[string]interface{}) (addattr []string, setattr []string, err error) { if len(services) == 0 { return nil, nil, nil } ocs, attrs, err := s.resolveServices(services) if err != nil { return nil, nil, err } for _, oc := range ocs { addattr = append(addattr, "objectclass="+oc) } for name, vals := range attrs { for _, v := range vals { setattr = append(setattr, name+"="+v) } } return addattr, setattr, nil } 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 via the IPA API (user_add). IPA // assigns uidNumber/gidNumber/homeDirectory/ipaUniqueID and the default user // group; gsc* service attributes/objectClasses are applied via addattr/setattr. func (s *LDAPService) CreateUser(req *types.LDAPUserCreate) (*types.LDAPUser, error) { if err := s.requireIPA(); err != nil { return nil, err } opts := map[string]interface{}{ "givenname": req.FirstName, "sn": req.LastName, } if req.Email != "" { opts["mail"] = req.Email } if req.Phone != "" { opts["telephonenumber"] = req.Phone } if req.Title != "" { opts["title"] = req.Title } if req.Shell != "" { opts["loginshell"] = req.Shell } if req.Password != "" { opts["userpassword"] = req.Password } addattr, setattr, err := s.ipaServiceOptions(req.Services) if err != nil { return nil, fmt.Errorf("invalid services: %w", err) } if len(addattr) > 0 { opts["addattr"] = addattr } if len(setattr) > 0 { opts["setattr"] = setattr } if _, err := s.ipa.Command("user_add", []interface{}{req.UID}, opts); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } return s.GetUser(req.UID) } // UpdateUser updates a user's attributes via the IPA API (user_mod). The // enable/disable transition is applied through user_enable/user_disable rather // than a raw nsAccountLock write. func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.LDAPUser, error) { if err := s.requireIPA(); err != nil { return nil, err } opts := map[string]interface{}{} if req.FirstName != nil { opts["givenname"] = *req.FirstName } if req.LastName != nil { opts["sn"] = *req.LastName } if req.FirstName != nil || req.LastName != nil { first, last := "", "" if req.FirstName != nil { first = *req.FirstName } if req.LastName != nil { last = *req.LastName } if display := strings.TrimSpace(first + " " + last); display != "" { opts["cn"] = display opts["displayname"] = display } } if req.Email != nil { opts["mail"] = *req.Email } if req.Phone != nil { opts["telephonenumber"] = *req.Phone } if req.Title != nil { opts["title"] = *req.Title } if req.Shell != nil { opts["loginshell"] = *req.Shell } addattr, setattr, err := s.ipaServiceOptions(req.Services) if err != nil { return nil, fmt.Errorf("invalid services: %w", err) } if len(addattr) > 0 { opts["addattr"] = addattr } if len(setattr) > 0 { opts["setattr"] = setattr } if len(opts) > 0 { if _, err := s.ipa.Command("user_mod", []interface{}{uid}, opts); err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } } // Enable/disable is a separate IPA verb. if req.Disabled != nil { verb := "user_enable" if *req.Disabled { verb = "user_disable" } if _, err := s.ipa.Command(verb, []interface{}{uid}, nil); err != nil { return nil, fmt.Errorf("failed to %s user: %w", strings.TrimPrefix(verb, "user_"), err) } } return s.GetUser(uid) } // DisableUser disables a user account via the IPA API (user_disable). func (s *LDAPService) DisableUser(uid string) error { if err := s.requireIPA(); err != nil { return err } _, err := s.ipa.Command("user_disable", []interface{}{uid}, nil) return err } // ResetPassword resets a user's password via the IPA API (passwd). Note: an // admin-set password is marked expired by IPA (the user must change it at next // login) — standard FreeIPA behaviour. func (s *LDAPService) ResetPassword(uid, newPassword string) error { if err := s.requireIPA(); err != nil { return err } _, err := s.ipa.Command("passwd", []interface{}{uid}, map[string]interface{}{"password": newPassword}) return err } // 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 via the IPA API (group_add). IPA assigns the // gidNumber and ipaUniqueID and sets the correct objectClasses. func (s *LDAPService) CreateGroup(req *types.LDAPGroupCreate) (*types.LDAPGroup, error) { if err := s.requireIPA(); err != nil { return nil, err } opts := map[string]interface{}{} if req.Description != "" { opts["description"] = req.Description } if _, err := s.ipa.Command("group_add", []interface{}{req.CN}, opts); err != nil { return nil, fmt.Errorf("failed to create group: %w", err) } return s.GetGroup(req.CN) } // UpdateGroup updates a group's attributes via the IPA API (group_mod). func (s *LDAPService) UpdateGroup(cn string, req *types.LDAPGroupUpdate) (*types.LDAPGroup, error) { if err := s.requireIPA(); err != nil { return nil, err } opts := map[string]interface{}{} if req.Description != nil { opts["description"] = *req.Description } if len(opts) > 0 { if _, err := s.ipa.Command("group_mod", []interface{}{cn}, opts); err != nil { return nil, fmt.Errorf("failed to update group: %w", err) } } return s.GetGroup(cn) } // DeleteGroup deletes a group via the IPA API (group_del). func (s *LDAPService) DeleteGroup(cn string) error { if err := s.requireIPA(); err != nil { return err } _, err := s.ipa.Command("group_del", []interface{}{cn}, nil) return err } // 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 user members to a group via the IPA API // (group_add_member). IPA resolves uids to member DNs itself. func (s *LDAPService) AddGroupMembers(cn string, uids []string) error { if err := s.requireIPA(); err != nil { return err } _, err := s.ipa.Command("group_add_member", []interface{}{cn}, map[string]interface{}{"user": uids}) return err } // RemoveGroupMember removes a user member from a group via the IPA API // (group_remove_member). func (s *LDAPService) RemoveGroupMember(cn, uid string) error { if err := s.requireIPA(); err != nil { return err } _, err := s.ipa.Command("group_remove_member", []interface{}{cn}, map[string]interface{}{"user": []string{uid}}) return err } 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"), } }