Files
gsc-ops-api/internal/client/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

227 lines
5.0 KiB
Go

package client
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/config"
)
// LDAPClient manages a pool of LDAP connections
type LDAPClient struct {
cfg config.LDAPConfig
pool chan *ldap.Conn
mu sync.Mutex
logger zerolog.Logger
}
// NewLDAPClient creates a new LDAP client with a connection pool
func NewLDAPClient(cfg config.LDAPConfig, logger zerolog.Logger) (*LDAPClient, error) {
if len(cfg.Servers) == 0 {
return nil, fmt.Errorf("no LDAP servers configured")
}
c := &LDAPClient{
cfg: cfg,
pool: make(chan *ldap.Conn, cfg.PoolSize),
logger: logger.With().Str("component", "ldap").Logger(),
}
// Pre-fill pool with connections
for i := 0; i < cfg.PoolSize; i++ {
conn, err := c.connect()
if err != nil {
c.logger.Warn().Err(err).Int("index", i).Msg("failed to create initial LDAP connection")
continue
}
c.pool <- conn
}
return c, nil
}
func (c *LDAPClient) connect() (*ldap.Conn, error) {
var lastErr error
for _, server := range c.cfg.Servers {
var conn *ldap.Conn
var err error
if c.cfg.UseTLS {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if c.cfg.CAFile != "" {
caCert, err := os.ReadFile(c.cfg.CAFile)
if err != nil {
lastErr = fmt.Errorf("failed to read CA file: %w", err)
continue
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
tlsCfg.RootCAs = pool
}
conn, err = ldap.DialURL(server, ldap.DialWithTLSConfig(tlsCfg))
} else {
conn, err = ldap.DialURL(server)
}
if err != nil {
lastErr = fmt.Errorf("failed to connect to %s: %w", server, err)
continue
}
conn.SetTimeout(10 * time.Second)
if err := conn.Bind(c.cfg.BindDN, c.cfg.BindPass); err != nil {
conn.Close()
lastErr = fmt.Errorf("failed to bind to %s: %w", server, err)
continue
}
return conn, nil
}
return nil, fmt.Errorf("all LDAP servers failed: %w", lastErr)
}
// Acquire gets a connection from the pool, creating one if needed
func (c *LDAPClient) Acquire() (*ldap.Conn, error) {
select {
case conn := <-c.pool:
// Test the connection with a no-op search
_, err := conn.Search(&ldap.SearchRequest{
BaseDN: "",
Scope: ldap.ScopeBaseObject,
Filter: "(objectClass=*)",
SizeLimit: 1,
})
if err != nil {
conn.Close()
return c.connect()
}
return conn, nil
default:
return c.connect()
}
}
// Release returns a connection to the pool
func (c *LDAPClient) Release(conn *ldap.Conn) {
if conn == nil {
return
}
select {
case c.pool <- conn:
default:
conn.Close()
}
}
// Close closes all pooled connections
func (c *LDAPClient) Close() {
close(c.pool)
for conn := range c.pool {
conn.Close()
}
}
// Health checks LDAP connectivity
func (c *LDAPClient) Health() error {
conn, err := c.Acquire()
if err != nil {
return err
}
defer c.Release(conn)
return nil
}
// Search executes an LDAP search
func (c *LDAPClient) Search(baseDN, filter string, attrs []string, sizeLimit int) ([]*ldap.Entry, error) {
conn, err := c.Acquire()
if err != nil {
return nil, fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
sr, err := conn.Search(&ldap.SearchRequest{
BaseDN: baseDN,
Scope: ldap.ScopeWholeSubtree,
Filter: filter,
Attributes: attrs,
SizeLimit: sizeLimit,
})
if err != nil {
// FreeIPA returns SizeLimitExceeded with partial results
if ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) && sr != nil {
return sr.Entries, nil
}
return nil, fmt.Errorf("LDAP search failed: %w", err)
}
return sr.Entries, nil
}
// SearchOne executes an LDAP search expecting exactly one result
func (c *LDAPClient) SearchOne(baseDN, filter string, attrs []string) (*ldap.Entry, error) {
entries, err := c.Search(baseDN, filter, attrs, 1)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
return entries[0], nil
}
// Add adds an LDAP entry
func (c *LDAPClient) Add(req *ldap.AddRequest) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Add(req)
}
// Modify modifies an LDAP entry
func (c *LDAPClient) Modify(req *ldap.ModifyRequest) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Modify(req)
}
// Delete deletes an LDAP entry
func (c *LDAPClient) Delete(dn string) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
return conn.Del(&ldap.DelRequest{DN: dn})
}
// PasswordModify changes a user's password
func (c *LDAPClient) PasswordModify(userDN, newPassword string) error {
conn, err := c.Acquire()
if err != nil {
return fmt.Errorf("failed to acquire LDAP connection: %w", err)
}
defer c.Release(conn)
_, err = conn.PasswordModify(&ldap.PasswordModifyRequest{
UserIdentity: userDN,
NewPassword: newPassword,
})
return err
}