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" ) // LDAPEntityService handles generic LDAP entity CRUD operations type LDAPEntityService struct { client *client.LDAPClient baseDN string registry *schema.Registry logger zerolog.Logger } // NewLDAPEntityService creates a new entity service func NewLDAPEntityService(ldapClient *client.LDAPClient, baseDN string, registry *schema.Registry, logger zerolog.Logger) *LDAPEntityService { return &LDAPEntityService{ client: ldapClient, baseDN: baseDN, registry: registry, logger: logger.With().Str("service", "ldap-entities").Logger(), } } // entityBaseDN returns the full base DN for an entity type func (s *LDAPEntityService) entityBaseDN(et *schema.EntityTypeDef) string { return et.BaseDN + "," + s.baseDN } // entityDN returns the full DN for a specific entity func (s *LDAPEntityService) entityDN(et *schema.EntityTypeDef, rdnValue string) string { return fmt.Sprintf("%s=%s,%s", et.RDNAttribute, ldap.EscapeFilter(rdnValue), s.entityBaseDN(et)) } // entityAttrs returns all searchable LDAP attribute names for an entity type func (s *LDAPEntityService) entityAttrs(et *schema.EntityTypeDef) []string { attrs := []string{"objectClass"} for _, ocName := range et.ObjectClasses { oc := s.registry.GetObjectClass(ocName) if oc == nil { continue } attrs = append(attrs, oc.Must...) attrs = append(attrs, oc.May...) } // Deduplicate seen := make(map[string]bool, len(attrs)) unique := make([]string, 0, len(attrs)) for _, a := range attrs { if !seen[a] { seen[a] = true unique = append(unique, a) } } return unique } // ListEntities searches for entities of a given type func (s *LDAPEntityService) ListEntities(typeName, search string, limit int) ([]types.LDAPEntity, error) { et := s.registry.GetEntityType(typeName) if et == nil { return nil, fmt.Errorf("unknown entity type: %s", typeName) } filter := et.SearchFilter if search != "" { escaped := ldap.EscapeFilter(search) // Search by RDN attribute or description filter = fmt.Sprintf("(&%s(|(%s=*%s*)(gscDescription=*%s*)))", et.SearchFilter, et.RDNAttribute, escaped, escaped) } attrs := s.entityAttrs(et) entries, err := s.client.Search(s.entityBaseDN(et), filter, attrs, limit) if err != nil { return nil, fmt.Errorf("entity search failed: %w", err) } entities := make([]types.LDAPEntity, 0, len(entries)) for _, entry := range entries { entities = append(entities, s.entryToEntity(entry, et)) } return entities, nil } // GetEntity retrieves a single entity by its RDN value func (s *LDAPEntityService) GetEntity(typeName, rdnValue string) (*types.LDAPEntity, error) { et := s.registry.GetEntityType(typeName) if et == nil { return nil, fmt.Errorf("unknown entity type: %s", typeName) } filter := fmt.Sprintf("(&%s(%s=%s))", et.SearchFilter, et.RDNAttribute, ldap.EscapeFilter(rdnValue)) attrs := s.entityAttrs(et) entry, err := s.client.SearchOne(s.entityBaseDN(et), filter, attrs) if err != nil { return nil, fmt.Errorf("entity lookup failed: %w", err) } if entry == nil { return nil, nil } entity := s.entryToEntity(entry, et) return &entity, nil } // CreateEntity creates a new entity func (s *LDAPEntityService) CreateEntity(typeName string, req *types.LDAPEntityCreate) (*types.LDAPEntity, error) { et := s.registry.GetEntityType(typeName) if et == nil { return nil, fmt.Errorf("unknown entity type: %s", typeName) } // Resolve attributes from JSON names to LDAP names ldapAttrs, err := s.resolveEntityAttrs(et, req.Attributes) if err != nil { return nil, err } // Validate required attributes for _, reqAttr := range et.RequiredAttrs { if _, ok := ldapAttrs[reqAttr]; !ok { // Try to find JSON name for better error message def := s.registry.GetAttr(reqAttr) jsonName := reqAttr if def != nil { jsonName = def.JSONName } return nil, fmt.Errorf("required attribute missing: %s", jsonName) } } // Determine RDN value rdnVals, ok := ldapAttrs[et.RDNAttribute] if !ok || len(rdnVals) == 0 { return nil, fmt.Errorf("RDN attribute %s is required", et.RDNAttribute) } rdnValue := rdnVals[0] // Build DN dn := s.entityDN(et, rdnValue) // Add audit timestamps now := time.Now().UTC().Format("20060102150405Z") ldapAttrs["gscCreatedAt"] = []string{now} ldapAttrs["gscModifiedAt"] = []string{now} // Create LDAP entry addReq := ldap.NewAddRequest(dn, nil) addReq.Attribute("objectClass", et.ObjectClasses) for attrName, vals := range ldapAttrs { addReq.Attribute(attrName, vals) } if err := s.client.Add(addReq); err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) { return nil, fmt.Errorf("CONFLICT: entity already exists: %s=%s", et.RDNAttribute, rdnValue) } return nil, fmt.Errorf("failed to create entity: %w", err) } return s.GetEntity(typeName, rdnValue) } // UpdateEntity modifies an existing entity func (s *LDAPEntityService) UpdateEntity(typeName, rdnValue string, req *types.LDAPEntityUpdate) (*types.LDAPEntity, error) { et := s.registry.GetEntityType(typeName) if et == nil { return nil, fmt.Errorf("unknown entity type: %s", typeName) } // Resolve attributes ldapAttrs, err := s.resolveEntityAttrs(et, req.Attributes) if err != nil { return nil, err } if len(ldapAttrs) == 0 { return s.GetEntity(typeName, rdnValue) } dn := s.entityDN(et, rdnValue) modReq := ldap.NewModifyRequest(dn, nil) for attrName, vals := range ldapAttrs { modReq.Replace(attrName, vals) } // Update audit timestamp now := time.Now().UTC().Format("20060102150405Z") modReq.Replace("gscModifiedAt", []string{now}) if err := s.client.Modify(modReq); err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { return nil, fmt.Errorf("NOT_FOUND: entity not found: %s=%s", et.RDNAttribute, rdnValue) } return nil, fmt.Errorf("failed to update entity: %w", err) } return s.GetEntity(typeName, rdnValue) } // DeleteEntity removes an entity func (s *LDAPEntityService) DeleteEntity(typeName, rdnValue string) error { et := s.registry.GetEntityType(typeName) if et == nil { return fmt.Errorf("unknown entity type: %s", typeName) } dn := s.entityDN(et, rdnValue) if err := s.client.Delete(dn); err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { return fmt.Errorf("NOT_FOUND: entity not found: %s=%s", et.RDNAttribute, rdnValue) } return fmt.Errorf("failed to delete entity: %w", err) } return nil } // resolveEntityAttrs converts JSON attribute names to LDAP attribute names with type conversion func (s *LDAPEntityService) resolveEntityAttrs(et *schema.EntityTypeDef, attrs map[string]interface{}) (map[string][]string, error) { ldapAttrs := make(map[string][]string) for jsonName, value := range attrs { // Try to find by JSON name in the entity's domain def := s.registry.GetAttrByJSON(et.Domain, jsonName) if def == nil { // Also try common domain def = s.registry.GetAttrByJSON("common", jsonName) } if def == nil { // Try as direct LDAP name def = s.registry.GetAttr(jsonName) } if def == nil { return nil, fmt.Errorf("unknown attribute: %s", jsonName) } if def.ReadOnly { continue } vals, err := s.registry.GoValueToLDAP(def, value) if err != nil { return nil, fmt.Errorf("attribute %s: %w", jsonName, err) } if vals != nil { ldapAttrs[def.LDAPName] = vals } } return ldapAttrs, nil } // entryToEntity converts an LDAP entry to a generic entity response func (s *LDAPEntityService) entryToEntity(entry *ldap.Entry, et *schema.EntityTypeDef) types.LDAPEntity { attrs := make(map[string]interface{}) for _, ldapAttr := range entry.Attributes { if ldapAttr.Name == "objectClass" { continue } def := s.registry.GetAttr(ldapAttr.Name) if def == nil { // Include unregistered attrs as raw strings if len(ldapAttr.Values) == 1 { attrs[ldapAttr.Name] = ldapAttr.Values[0] } else if len(ldapAttr.Values) > 1 { attrs[ldapAttr.Name] = ldapAttr.Values } continue } val := s.registry.LDAPValueToGo(def, ldapAttr.Values) if val != nil { attrs[def.JSONName] = val } } // Extract RDN value rdnValue := "" if et.RDNAttribute == "cn" { rdnValue = entry.GetAttributeValue("cn") } else { rdnValue = entry.GetAttributeValue(et.RDNAttribute) } return types.LDAPEntity{ DN: entry.DN, Type: et.Name, RDN: rdnValue, ObjectClasses: entry.GetAttributeValues("objectClass"), Attributes: attrs, } } // ClassifyError classifies LDAP errors for HTTP status mapping func ClassifyError(err error) (string, string) { msg := err.Error() if strings.HasPrefix(msg, "CONFLICT:") { return "conflict", strings.TrimPrefix(msg, "CONFLICT: ") } if strings.HasPrefix(msg, "NOT_FOUND:") { return "not_found", strings.TrimPrefix(msg, "NOT_FOUND: ") } if strings.Contains(msg, "unknown entity type") || strings.Contains(msg, "unknown attribute") || strings.Contains(msg, "required attribute") { return "validation", msg } return "internal", msg }