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>
227 lines
5.0 KiB
Go
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
|
|
}
|