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:
Claude (gsc-ops-api init)
2026-05-03 20:06:02 +02:00
commit 3847eb2036
68 changed files with 12982 additions and 0 deletions

View 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
}