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>
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user