Compare commits

...

4 Commits

Author SHA1 Message Date
66bfb4ebfa Merge pull request 'FreeIPA-API writes (user/group mutations)' (#3) from feat/freeipa-api-writes into main 2026-06-01 12:05:57 +00:00
Claude (gsc-ops-api init)
90f98671fc feat(ldap): perform user/group writes via the FreeIPA API
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 <noreply@anthropic.com>
2026-06-01 14:05:19 +02:00
f6a9d5e312 Merge pull request 'fix(certs): default to active certs when no search term' (#2) from fix/certs-list-default into main 2026-06-01 10:14:15 +00:00
Claude (gsc-ops-api init)
30268db4be fix(certs): default to active certs when no search term
EJBCA's certificate/search REST endpoint rejects an empty criteria list
("Invalid criteria value, cannot be empty"), so GET /certs with no ?search
returned a 500. Default to a STATUS=CERT_ACTIVE criterion in that case so the
list endpoint returns active certificates. Search-by-query is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:13:52 +02:00
4 changed files with 408 additions and 172 deletions

View File

@@ -129,6 +129,19 @@ func main() {
logger.Info().Int("poolSize", cfg.LDAP.PoolSize).Msg("Connected to LDAP") 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 // Initialize PowerDNS client
var pdnsClient *client.PowerDNSClient var pdnsClient *client.PowerDNSClient
if cfg.PowerDNS.BaseURL != "" { 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") logger.Info().Int("attrs", len(registry.AllUserAttrs())).Int("entities", len(registry.AllEntityTypes())).Msg("Schema registry initialized")
// Create services // 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) ldapEntitySvc := service.NewLDAPEntityService(ldapClient, cfg.LDAP.BaseDN, registry, logger)
dnsSvc := service.NewDNSService(pdnsClient, logger) dnsSvc := service.NewDNSService(pdnsClient, logger)
dbSvc := service.NewDatabaseService(coreDB.Pool(), logger) dbSvc := service.NewDatabaseService(coreDB.Pool(), logger)

226
internal/client/freeipa.go Normal file
View File

@@ -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
}

View File

@@ -38,6 +38,15 @@ func (s *CertificateService) ListCertificates(search string, limit int) ([]types
Value: search, Value: search,
Operation: "LIKE", Operation: "LIKE",
}) })
} else {
// EJBCA rejects an empty criteria list ("Invalid criteria value,
// cannot be empty"). With no search term, default to listing active
// certificates so GET /certs returns a useful result instead of 500.
criteria = append(criteria, client.CertSearchCriterion{
Property: "STATUS",
Value: "CERT_ACTIVE",
Operation: "EQUAL",
})
} }
certs, err := s.client.SearchCertificates(&client.CertSearchRequest{ certs, err := s.client.SearchCertificates(&client.CertSearchRequest{

View File

@@ -3,7 +3,6 @@ package service
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -13,24 +12,59 @@ import (
"github.com/gosec/gsc-ops-api/pkg/types" "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 { type LDAPService struct {
client *client.LDAPClient client *client.LDAPClient
ipa *client.FreeIPAClient
baseDN string baseDN string
logger zerolog.Logger logger zerolog.Logger
registry *schema.Registry registry *schema.Registry
} }
// NewLDAPService creates a new LDAP service // NewLDAPService creates a new LDAP service. ipa may be nil (writes then error
func NewLDAPService(ldapClient *client.LDAPClient, baseDN string, logger zerolog.Logger, registry *schema.Registry) *LDAPService { // 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{ return &LDAPService{
client: ldapClient, client: ldapClient,
ipa: ipa,
baseDN: baseDN, baseDN: baseDN,
logger: logger.With().Str("service", "ldap").Logger(), logger: logger.With().Str("service", "ldap").Logger(),
registry: registry, 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 { func (s *LDAPService) userBaseDN() string {
return "cn=users,cn=accounts," + s.baseDN 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 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) { func (s *LDAPService) CreateUser(req *types.LDAPUserCreate) (*types.LDAPUser, error) {
dn := s.userDN(req.UID) if err := s.requireIPA(); err != nil {
return nil, err
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})
opts := map[string]interface{}{
"givenname": req.FirstName,
"sn": req.LastName,
}
if req.Email != "" { if req.Email != "" {
addReq.Attribute("mail", []string{req.Email}) opts["mail"] = req.Email
} }
if req.Phone != "" { if req.Phone != "" {
addReq.Attribute("telephoneNumber", []string{req.Phone}) opts["telephonenumber"] = req.Phone
} }
if req.Title != "" { if req.Title != "" {
addReq.Attribute("title", []string{req.Title}) opts["title"] = req.Title
} }
shell := "/bin/bash"
if req.Shell != "" { 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 req.Password != "" {
if err := s.client.PasswordModify(dn, req.Password); err != nil { opts["userpassword"] = req.Password
s.logger.Warn().Err(err).Str("uid", req.UID).Msg("user created but password set failed") }
}
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) 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) { func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.LDAPUser, error) {
dn := s.userDN(uid) if err := s.requireIPA(); err != nil {
modReq := ldap.NewModifyRequest(dn, nil) return nil, err
modified := false }
opts := map[string]interface{}{}
if req.FirstName != nil { if req.FirstName != nil {
modReq.Replace("givenName", []string{*req.FirstName}) opts["givenname"] = *req.FirstName
modified = true
} }
if req.LastName != nil { if req.LastName != nil {
modReq.Replace("sn", []string{*req.LastName}) opts["sn"] = *req.LastName
modified = true
} }
if req.FirstName != nil || req.LastName != nil { if req.FirstName != nil || req.LastName != nil {
// Update display name and cn
first, last := "", "" first, last := "", ""
if req.FirstName != nil { if req.FirstName != nil {
first = *req.FirstName first = *req.FirstName
@@ -277,101 +295,73 @@ func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.
if req.LastName != nil { if req.LastName != nil {
last = *req.LastName last = *req.LastName
} }
if first != "" || last != "" { if display := strings.TrimSpace(first + " " + last); display != "" {
display := strings.TrimSpace(first + " " + last) opts["cn"] = display
if display != "" { opts["displayname"] = display
modReq.Replace("displayName", []string{display})
modReq.Replace("cn", []string{display})
}
} }
} }
if req.Email != nil { if req.Email != nil {
modReq.Replace("mail", []string{*req.Email}) opts["mail"] = *req.Email
modified = true
} }
if req.Phone != nil { if req.Phone != nil {
modReq.Replace("telephoneNumber", []string{*req.Phone}) opts["telephonenumber"] = *req.Phone
modified = true
} }
if req.Title != nil { if req.Title != nil {
modReq.Replace("title", []string{*req.Title}) opts["title"] = *req.Title
modified = true
} }
if req.Shell != nil { if req.Shell != nil {
modReq.Replace("loginShell", []string{*req.Shell}) opts["loginshell"] = *req.Shell
modified = true
} }
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 { if req.Disabled != nil {
verb := "user_enable"
if *req.Disabled { if *req.Disabled {
modReq.Replace("nsAccountLock", []string{"TRUE"}) verb = "user_disable"
} else {
modReq.Replace("nsAccountLock", []string{"FALSE"})
} }
modified = true 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)
// 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) 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 { func (s *LDAPService) DisableUser(uid string) error {
dn := s.userDN(uid) if err := s.requireIPA(); err != nil {
modReq := ldap.NewModifyRequest(dn, nil) return err
modReq.Replace("nsAccountLock", []string{"TRUE"}) }
return s.client.Modify(modReq) _, 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 { func (s *LDAPService) ResetPassword(uid, newPassword string) error {
dn := s.userDN(uid) if err := s.requireIPA(); err != nil {
return s.client.PasswordModify(dn, newPassword) return err
}
_, err := s.ipa.Command("passwd", []interface{}{uid}, map[string]interface{}{"password": newPassword})
return err
} }
// GetUserGroups lists groups a user belongs to // GetUserGroups lists groups a user belongs to
@@ -431,44 +421,46 @@ func (s *LDAPService) GetGroup(cn string) (*types.LDAPGroup, error) {
return &group, nil 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) { func (s *LDAPService) CreateGroup(req *types.LDAPGroupCreate) (*types.LDAPGroup, error) {
dn := s.groupDN(req.CN) if err := s.requireIPA(); err != nil {
return nil, err
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})
} }
opts := map[string]interface{}{}
if err := s.client.Add(addReq); err != nil { 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 nil, fmt.Errorf("failed to create group: %w", err)
} }
return s.GetGroup(req.CN) 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) { func (s *LDAPService) UpdateGroup(cn string, req *types.LDAPGroupUpdate) (*types.LDAPGroup, error) {
dn := s.groupDN(cn) if err := s.requireIPA(); err != nil {
modReq := ldap.NewModifyRequest(dn, nil) return nil, err
}
opts := map[string]interface{}{}
if req.Description != nil { if req.Description != nil {
modReq.Replace("description", []string{*req.Description}) opts["description"] = *req.Description
} }
if len(opts) > 0 {
if err := s.client.Modify(modReq); err != nil { if _, err := s.ipa.Command("group_mod", []interface{}{cn}, opts); err != nil {
return nil, fmt.Errorf("failed to update group: %w", err) return nil, fmt.Errorf("failed to update group: %w", err)
}
} }
return s.GetGroup(cn) 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 { func (s *LDAPService) DeleteGroup(cn string) error {
dn := s.groupDN(cn) if err := s.requireIPA(); err != nil {
return s.client.Delete(dn) return err
}
_, err := s.ipa.Command("group_del", []interface{}{cn}, nil)
return err
} }
// GetGroupMembers lists members of a group // GetGroupMembers lists members of a group
@@ -483,28 +475,24 @@ func (s *LDAPService) GetGroupMembers(cn string) ([]string, error) {
return group.Members, nil 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 { func (s *LDAPService) AddGroupMembers(cn string, uids []string) error {
dn := s.groupDN(cn) if err := s.requireIPA(); err != nil {
modReq := ldap.NewModifyRequest(dn, nil) return err
for _, uid := range uids {
memberDN := s.userDN(uid)
modReq.Add("member", []string{memberDN})
} }
_, err := s.ipa.Command("group_add_member", []interface{}{cn}, map[string]interface{}{"user": uids})
return s.client.Modify(modReq) 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 { func (s *LDAPService) RemoveGroupMember(cn, uid string) error {
dn := s.groupDN(cn) if err := s.requireIPA(); err != nil {
memberDN := s.userDN(uid) return err
}
modReq := ldap.NewModifyRequest(dn, nil) _, err := s.ipa.Command("group_remove_member", []interface{}{cn}, map[string]interface{}{"user": []string{uid}})
modReq.Delete("member", []string{memberDN}) return err
return s.client.Modify(modReq)
} }
func (s *LDAPService) entryToUser(entry *ldap.Entry, includeServices bool) types.LDAPUser { func (s *LDAPService) entryToUser(entry *ldap.Entry, includeServices bool) types.LDAPUser {