From 90f98671fcc26dd7d6bdabb35ff4571ea24e40ca Mon Sep 17 00:00:00 2001 From: "Claude (gsc-ops-api init)" Date: Mon, 1 Jun 2026 14:05:19 +0200 Subject: [PATCH] feat(ldap): perform user/group writes via the FreeIPA API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw LDAP adds/modifies bypassed FreeIPA's framework, so group/user creates failed with Object Class Violation (no gidNumber/uidNumber/ipaUniqueID) and deletes/mods needed ACIs the bind account couldn't exercise as a plain LDAP write. Route all MUTATIONS through the FreeIPA JSON-RPC API instead; reads stay on direct LDAP. - internal/client/freeipa.go: new JSON-RPC client (form login_password → ipa_session cookie, re-auth on 401, multi-server failover, TLS via the configured CA). Derives the API host + login uid from the LDAP config. - internal/service/ldap.go: CreateGroup/UpdateGroup/DeleteGroup/AddGroupMembers/ RemoveGroupMember → group_add/_mod/_del/_add_member/_remove_member; CreateUser/UpdateUser/DisableUser/ResetPassword → user_add/_mod/_disable (+_enable)/passwd. Services map → addattr(objectclass)/setattr. Writes error cleanly when the IPA client is unconfigured. - cmd/server/main.go: build the FreeIPA client from the LDAP config and inject it into the LDAP service. Verified live: group create (IPA-assigned gidNumber), get, add/remove member, delete all succeed; reads unchanged. Co-Authored-By: Claude Opus 4.8 --- cmd/server/main.go | 15 +- internal/client/freeipa.go | 226 +++++++++++++++++++++++++ internal/service/ldap.go | 330 ++++++++++++++++++------------------- 3 files changed, 399 insertions(+), 172 deletions(-) create mode 100644 internal/client/freeipa.go diff --git a/cmd/server/main.go b/cmd/server/main.go index d16aa10..f7209b1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -129,6 +129,19 @@ func main() { logger.Info().Int("poolSize", cfg.LDAP.PoolSize).Msg("Connected to LDAP") } + // Initialize FreeIPA management-API client (for user/group MUTATIONS). + // Reads stay on direct LDAP; writes go through the IPA API so gid/uid + // Numbers, ipaUniqueID and objectClasses are assigned by the framework. + var ipaClient *client.FreeIPAClient + if len(cfg.LDAP.Servers) > 0 { + ipaClient, err = client.NewFreeIPAClient(cfg.LDAP.Servers, cfg.LDAP.BindDN, cfg.LDAP.BindPass, cfg.LDAP.CAFile, logger) + if err != nil { + logger.Warn().Err(err).Msg("Failed to create FreeIPA API client (LDAP writes disabled)") + } else { + logger.Info().Int("servers", len(cfg.LDAP.Servers)).Msg("FreeIPA management-API client initialized") + } + } + // Initialize PowerDNS client var pdnsClient *client.PowerDNSClient if cfg.PowerDNS.BaseURL != "" { @@ -193,7 +206,7 @@ func main() { logger.Info().Int("attrs", len(registry.AllUserAttrs())).Int("entities", len(registry.AllEntityTypes())).Msg("Schema registry initialized") // Create services - ldapSvc := service.NewLDAPService(ldapClient, cfg.LDAP.BaseDN, logger, registry) + ldapSvc := service.NewLDAPService(ldapClient, ipaClient, cfg.LDAP.BaseDN, logger, registry) ldapEntitySvc := service.NewLDAPEntityService(ldapClient, cfg.LDAP.BaseDN, registry, logger) dnsSvc := service.NewDNSService(pdnsClient, logger) dbSvc := service.NewDatabaseService(coreDB.Pool(), logger) diff --git a/internal/client/freeipa.go b/internal/client/freeipa.go new file mode 100644 index 0000000..f380300 --- /dev/null +++ b/internal/client/freeipa.go @@ -0,0 +1,226 @@ +package client + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +// FreeIPAClient talks to the FreeIPA JSON-RPC management API +// (/ipa/session/json). It is used for MUTATIONS (user/group add, modify, +// delete, membership, password, enable/disable) because the IPA framework +// assigns uidNumber/gidNumber/ipaUniqueID, sets the correct objectClasses and +// enforces IPA's own logic — none of which a raw LDAP add does. Reads stay on +// direct LDAP (LDAPClient). +// +// Auth: form login (login_password) with the svc account uid + password → +// ipa_session cookie. The cookie is reused and refreshed on 401. Requests fail +// over across the configured IPA servers. +type FreeIPAClient struct { + servers []string // bare hostnames, e.g. fihelvid01.gosec.auth + user string // uid (extracted from the bind DN) + pass string + http *http.Client + logger zerolog.Logger + + mu sync.Mutex + cookie string // "ipa_session=..." + active string // hostname the current cookie belongs to +} + +// NewFreeIPAClient builds the client from the LDAP config. `servers` are the +// ldap(s):// URLs; the IPA API host is the same host on https/443. `bindDN` is +// the full svc DN (uid=svc-ops-api,...); the uid is extracted for the API login. +func NewFreeIPAClient(servers []string, bindDN, password, caFile string, logger zerolog.Logger) (*FreeIPAClient, error) { + hosts := make([]string, 0, len(servers)) + for _, s := range servers { + h := s + if i := strings.Index(h, "://"); i >= 0 { + h = h[i+3:] + } + if i := strings.IndexByte(h, ':'); i >= 0 { + h = h[:i] + } + if h != "" { + hosts = append(hosts, h) + } + } + if len(hosts) == 0 { + return nil, fmt.Errorf("no IPA servers configured") + } + + uid := bindDN + for _, part := range strings.Split(bindDN, ",") { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "uid=") { + uid = part[4:] + break + } + } + + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + if caFile != "" { + caCert, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read IPA CA file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("parse IPA CA file %s", caFile) + } + tlsCfg.RootCAs = pool + } + + return &FreeIPAClient{ + servers: hosts, + user: uid, + pass: password, + http: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + }, + logger: logger.With().Str("component", "freeipa").Logger(), + }, nil +} + +// login authenticates against one host and stores the session cookie. +func (c *FreeIPAClient) login(host string) error { + form := url.Values{"user": {c.user}, "password": {c.pass}} + req, err := http.NewRequest("POST", + fmt.Sprintf("https://%s/ipa/session/login_password", host), + strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "text/plain") + req.Header.Set("Referer", fmt.Sprintf("https://%s/ipa", host)) + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("IPA login failed on %s: HTTP %d %s", host, resp.StatusCode, strings.TrimSpace(string(body))) + } + for _, ck := range resp.Cookies() { + if ck.Name == "ipa_session" { + c.cookie = ck.Name + "=" + ck.Value + c.active = host + return nil + } + } + return fmt.Errorf("IPA login on %s returned no ipa_session cookie", host) +} + +// ensureSession logs in (trying each server) if there is no current cookie. +func (c *FreeIPAClient) ensureSession() error { + if c.cookie != "" { + return nil + } + var lastErr error + for _, h := range c.servers { + if err := c.login(h); err != nil { + lastErr = err + c.logger.Warn().Err(err).Str("host", h).Msg("IPA login attempt failed") + continue + } + return nil + } + return fmt.Errorf("all IPA logins failed: %w", lastErr) +} + +type ipaError struct { + Code int `json:"code"` + Name string `json:"name"` + Message string `json:"message"` +} + +type ipaResponse struct { + Result json.RawMessage `json:"result"` + Error *ipaError `json:"error"` +} + +// Command invokes an IPA method. `args` are the positional primary keys (e.g. +// the cn or uid); `options` are the keyword options. Returns the raw `result`. +// Re-authenticates once on 401. Surfaces IPA's structured error on failure. +func (c *FreeIPAClient) Command(method string, args []interface{}, options map[string]interface{}) (json.RawMessage, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if options == nil { + options = map[string]interface{}{} + } + payload, err := json.Marshal(map[string]interface{}{ + "method": method, + "params": []interface{}{args, options}, + "id": 0, + }) + if err != nil { + return nil, err + } + + if err := c.ensureSession(); err != nil { + return nil, err + } + + res, status, err := c.post(payload) + if (status == http.StatusUnauthorized) || (err == nil && status == http.StatusForbidden) { + // Session expired — re-login once and retry. + c.cookie = "" + if err2 := c.ensureSession(); err2 != nil { + return nil, err2 + } + res, status, err = c.post(payload) + } + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("IPA %s: HTTP %d: %s", method, status, strings.TrimSpace(string(res))) + } + + var parsed ipaResponse + if err := json.Unmarshal(res, &parsed); err != nil { + return nil, fmt.Errorf("IPA %s: invalid response: %w", method, err) + } + if parsed.Error != nil { + return nil, fmt.Errorf("IPA %s: %s (%s)", method, parsed.Error.Message, parsed.Error.Name) + } + return parsed.Result, nil +} + +// post sends one JSON-RPC request to the active server. +func (c *FreeIPAClient) post(payload []byte) ([]byte, int, error) { + req, err := http.NewRequest("POST", + fmt.Sprintf("https://%s/ipa/session/json", c.active), + bytes.NewReader(payload)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", fmt.Sprintf("https://%s/ipa", c.active)) + req.Header.Set("Cookie", c.cookie) + + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return body, resp.StatusCode, err +} diff --git a/internal/service/ldap.go b/internal/service/ldap.go index aef5ab2..fcf343f 100644 --- a/internal/service/ldap.go +++ b/internal/service/ldap.go @@ -3,7 +3,6 @@ package service import ( "fmt" "strings" - "time" "github.com/go-ldap/ldap/v3" "github.com/rs/zerolog" @@ -13,24 +12,59 @@ import ( "github.com/gosec/gsc-ops-api/pkg/types" ) -// LDAPService handles FreeIPA user and group operations +// 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 -func NewLDAPService(ldapClient *client.LDAPClient, baseDN string, logger zerolog.Logger, registry *schema.Registry) *LDAPService { +// 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 } @@ -192,84 +226,68 @@ func (s *LDAPService) GetUserServices(uid string, domain string) (map[string]map return services, nil } -// CreateUser creates a new FreeIPA user +// 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) { - 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 err := s.requireIPA(); err != nil { + return nil, err + } + opts := map[string]interface{}{ + "givenname": req.FirstName, + "sn": req.LastName, + } if req.Email != "" { - addReq.Attribute("mail", []string{req.Email}) + opts["mail"] = req.Email } if req.Phone != "" { - addReq.Attribute("telephoneNumber", []string{req.Phone}) + opts["telephonenumber"] = req.Phone } if req.Title != "" { - addReq.Attribute("title", []string{req.Title}) + opts["title"] = req.Title } - - shell := "/bin/bash" if req.Shell != "" { - shell = req.Shell + opts["loginshell"] = 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") - } + 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 +// 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) { - dn := s.userDN(uid) - modReq := ldap.NewModifyRequest(dn, nil) - modified := false + if err := s.requireIPA(); err != nil { + return nil, err + } + opts := map[string]interface{}{} if req.FirstName != nil { - modReq.Replace("givenName", []string{*req.FirstName}) - modified = true + opts["givenname"] = *req.FirstName } if req.LastName != nil { - modReq.Replace("sn", []string{*req.LastName}) - modified = true + opts["sn"] = *req.LastName } if req.FirstName != nil || req.LastName != nil { - // Update display name and cn first, last := "", "" if req.FirstName != nil { first = *req.FirstName @@ -277,101 +295,73 @@ func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types. 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 display := strings.TrimSpace(first + " " + last); display != "" { + opts["cn"] = display + opts["displayname"] = display } } if req.Email != nil { - modReq.Replace("mail", []string{*req.Email}) - modified = true + opts["mail"] = *req.Email } if req.Phone != nil { - modReq.Replace("telephoneNumber", []string{*req.Phone}) - modified = true + opts["telephonenumber"] = *req.Phone } if req.Title != nil { - modReq.Replace("title", []string{*req.Title}) - modified = true + opts["title"] = *req.Title } if req.Shell != nil { - modReq.Replace("loginShell", []string{*req.Shell}) - modified = true + 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 { - modReq.Replace("nsAccountLock", []string{"TRUE"}) - } else { - modReq.Replace("nsAccountLock", []string{"FALSE"}) + verb = "user_disable" } - 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) + 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) } - - // 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 +// DisableUser disables a user account via the IPA API (user_disable). 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) + 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 +// 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 { - dn := s.userDN(uid) - return s.client.PasswordModify(dn, newPassword) + 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 @@ -431,44 +421,46 @@ func (s *LDAPService) GetGroup(cn string) (*types.LDAPGroup, error) { return &group, nil } -// CreateGroup creates a new group +// 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) { - 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.requireIPA(); err != nil { + return nil, err } - - if err := s.client.Add(addReq); err != nil { + 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 +// UpdateGroup updates a group's attributes via the IPA API (group_mod). func (s *LDAPService) UpdateGroup(cn string, req *types.LDAPGroupUpdate) (*types.LDAPGroup, error) { - dn := s.groupDN(cn) - modReq := ldap.NewModifyRequest(dn, nil) - + if err := s.requireIPA(); err != nil { + return nil, err + } + opts := map[string]interface{}{} if req.Description != nil { - modReq.Replace("description", []string{*req.Description}) + opts["description"] = *req.Description } - - if err := s.client.Modify(modReq); err != nil { - return nil, fmt.Errorf("failed to update group: %w", err) + 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 +// DeleteGroup deletes a group via the IPA API (group_del). func (s *LDAPService) DeleteGroup(cn string) error { - dn := s.groupDN(cn) - return s.client.Delete(dn) + 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 @@ -483,28 +475,24 @@ func (s *LDAPService) GetGroupMembers(cn string) ([]string, error) { return group.Members, nil } -// AddGroupMembers adds members to a group +// 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 { - dn := s.groupDN(cn) - modReq := ldap.NewModifyRequest(dn, nil) - - for _, uid := range uids { - memberDN := s.userDN(uid) - modReq.Add("member", []string{memberDN}) + if err := s.requireIPA(); err != nil { + return err } - - return s.client.Modify(modReq) + _, err := s.ipa.Command("group_add_member", []interface{}{cn}, map[string]interface{}{"user": uids}) + return err } -// RemoveGroupMember removes a member from a group +// RemoveGroupMember removes a user member from a group via the IPA API +// (group_remove_member). 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) + 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 {