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>
This commit is contained in:
321
internal/service/ldap_entities.go
Normal file
321
internal/service/ldap_entities.go
Normal file
@@ -0,0 +1,321 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user