Files
gsc-ops-api/internal/service/ldap.go
Claude (gsc-ops-api init) 3847eb2036 Initial import — snapshot from admin host /srv/gosec/gsc-ops-api
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>
2026-05-03 20:06:02 +02:00

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