Files
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

637 lines
19 KiB
Go

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"),
}
}