This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.
The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.
Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.
Future changes go through PRs against this repo — no more bin-only
deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
649 lines
18 KiB
Go
649 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
type LDAPService struct {
|
|
client *client.LDAPClient
|
|
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 {
|
|
return &LDAPService{
|
|
client: ldapClient,
|
|
baseDN: baseDN,
|
|
logger: logger.With().Str("service", "ldap").Logger(),
|
|
registry: registry,
|
|
}
|
|
}
|
|
|
|
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
|
|
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 req.Email != "" {
|
|
addReq.Attribute("mail", []string{req.Email})
|
|
}
|
|
if req.Phone != "" {
|
|
addReq.Attribute("telephoneNumber", []string{req.Phone})
|
|
}
|
|
if req.Title != "" {
|
|
addReq.Attribute("title", []string{req.Title})
|
|
}
|
|
|
|
shell := "/bin/bash"
|
|
if req.Shell != "" {
|
|
shell = 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")
|
|
}
|
|
}
|
|
|
|
return s.GetUser(req.UID)
|
|
}
|
|
|
|
// UpdateUser updates a user's attributes
|
|
func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.LDAPUser, error) {
|
|
dn := s.userDN(uid)
|
|
modReq := ldap.NewModifyRequest(dn, nil)
|
|
modified := false
|
|
|
|
if req.FirstName != nil {
|
|
modReq.Replace("givenName", []string{*req.FirstName})
|
|
modified = true
|
|
}
|
|
if req.LastName != nil {
|
|
modReq.Replace("sn", []string{*req.LastName})
|
|
modified = true
|
|
}
|
|
if req.FirstName != nil || req.LastName != nil {
|
|
// Update display name and cn
|
|
first, last := "", ""
|
|
if req.FirstName != nil {
|
|
first = *req.FirstName
|
|
}
|
|
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 req.Email != nil {
|
|
modReq.Replace("mail", []string{*req.Email})
|
|
modified = true
|
|
}
|
|
if req.Phone != nil {
|
|
modReq.Replace("telephoneNumber", []string{*req.Phone})
|
|
modified = true
|
|
}
|
|
if req.Title != nil {
|
|
modReq.Replace("title", []string{*req.Title})
|
|
modified = true
|
|
}
|
|
if req.Shell != nil {
|
|
modReq.Replace("loginShell", []string{*req.Shell})
|
|
modified = true
|
|
}
|
|
if req.Disabled != nil {
|
|
if *req.Disabled {
|
|
modReq.Replace("nsAccountLock", []string{"TRUE"})
|
|
} else {
|
|
modReq.Replace("nsAccountLock", []string{"FALSE"})
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
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)
|
|
}
|
|
|
|
// ResetPassword resets a user's password
|
|
func (s *LDAPService) ResetPassword(uid, newPassword string) error {
|
|
dn := s.userDN(uid)
|
|
return s.client.PasswordModify(dn, newPassword)
|
|
}
|
|
|
|
// 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
|
|
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.client.Add(addReq); err != nil {
|
|
return nil, fmt.Errorf("failed to create group: %w", err)
|
|
}
|
|
|
|
return s.GetGroup(req.CN)
|
|
}
|
|
|
|
// UpdateGroup updates a group's attributes
|
|
func (s *LDAPService) UpdateGroup(cn string, req *types.LDAPGroupUpdate) (*types.LDAPGroup, error) {
|
|
dn := s.groupDN(cn)
|
|
modReq := ldap.NewModifyRequest(dn, nil)
|
|
|
|
if req.Description != nil {
|
|
modReq.Replace("description", []string{*req.Description})
|
|
}
|
|
|
|
if err := s.client.Modify(modReq); err != nil {
|
|
return nil, fmt.Errorf("failed to update group: %w", err)
|
|
}
|
|
|
|
return s.GetGroup(cn)
|
|
}
|
|
|
|
// DeleteGroup deletes a group
|
|
func (s *LDAPService) DeleteGroup(cn string) error {
|
|
dn := s.groupDN(cn)
|
|
return s.client.Delete(dn)
|
|
}
|
|
|
|
// 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 members to a group
|
|
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})
|
|
}
|
|
|
|
return s.client.Modify(modReq)
|
|
}
|
|
|
|
// RemoveGroupMember removes a member from a group
|
|
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)
|
|
}
|
|
|
|
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"),
|
|
}
|
|
}
|