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>
274 lines
7.2 KiB
Go
274 lines
7.2 KiB
Go
package schema
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Registry is the central schema registry for all GoSec LDAP attributes,
|
|
// objectClasses, and entity types.
|
|
type Registry struct {
|
|
attrs map[string]*AttrDef // ldapName → AttrDef
|
|
attrsByJSON map[string]*AttrDef // "domain:jsonName" → AttrDef
|
|
domainAttrs map[string][]*AttrDef // domain → list of attrs
|
|
objectClasses map[string]*ObjectClassDef // OC name → ObjectClassDef
|
|
domainOC map[string]string // domain → auxiliary user OC name
|
|
entityTypes map[string]*EntityTypeDef // entity name → EntityTypeDef
|
|
}
|
|
|
|
// NewRegistry creates and populates the schema registry
|
|
func NewRegistry() *Registry {
|
|
r := &Registry{
|
|
attrs: make(map[string]*AttrDef),
|
|
attrsByJSON: make(map[string]*AttrDef),
|
|
domainAttrs: make(map[string][]*AttrDef),
|
|
objectClasses: make(map[string]*ObjectClassDef),
|
|
domainOC: make(map[string]string),
|
|
entityTypes: make(map[string]*EntityTypeDef),
|
|
}
|
|
r.registerAttributes()
|
|
r.registerObjectClasses()
|
|
r.registerEntities()
|
|
return r
|
|
}
|
|
|
|
func (r *Registry) addAttr(ldapName, jsonName string, typ AttrType, domain string, readOnly bool) {
|
|
def := &AttrDef{
|
|
LDAPName: ldapName,
|
|
JSONName: jsonName,
|
|
Type: typ,
|
|
Domain: domain,
|
|
ReadOnly: readOnly,
|
|
}
|
|
r.attrs[ldapName] = def
|
|
r.attrsByJSON[domain+":"+jsonName] = def
|
|
r.domainAttrs[domain] = append(r.domainAttrs[domain], def)
|
|
}
|
|
|
|
func (r *Registry) addObjectClass(name, kind string, must, may []string, domain string) {
|
|
r.objectClasses[name] = &ObjectClassDef{
|
|
Name: name,
|
|
Kind: kind,
|
|
Must: must,
|
|
May: may,
|
|
Domain: domain,
|
|
}
|
|
// Map domain → auxiliary user objectClass (first AUXILIARY wins)
|
|
if kind == "AUXILIARY" {
|
|
if _, exists := r.domainOC[domain]; !exists {
|
|
r.domainOC[domain] = name
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Registry) addEntityType(name, description string, objectClasses []string, baseDN, rdnAttr, searchFilter, domain string, requiredAttrs []string) {
|
|
r.entityTypes[name] = &EntityTypeDef{
|
|
Name: name,
|
|
Description: description,
|
|
ObjectClasses: objectClasses,
|
|
BaseDN: baseDN,
|
|
RDNAttribute: rdnAttr,
|
|
SearchFilter: searchFilter,
|
|
Domain: domain,
|
|
RequiredAttrs: requiredAttrs,
|
|
}
|
|
}
|
|
|
|
// GetAttr returns an attribute definition by LDAP name
|
|
func (r *Registry) GetAttr(ldapName string) *AttrDef {
|
|
return r.attrs[ldapName]
|
|
}
|
|
|
|
// GetAttrByJSON returns an attribute definition by domain and JSON name
|
|
func (r *Registry) GetAttrByJSON(domain, jsonName string) *AttrDef {
|
|
return r.attrsByJSON[domain+":"+jsonName]
|
|
}
|
|
|
|
// AttrsForDomain returns all attribute definitions for a domain
|
|
func (r *Registry) AttrsForDomain(domain string) []*AttrDef {
|
|
return r.domainAttrs[domain]
|
|
}
|
|
|
|
// AllDomains returns all registered domain names
|
|
func (r *Registry) AllDomains() []string {
|
|
domains := make([]string, 0, len(r.domainAttrs))
|
|
for d := range r.domainAttrs {
|
|
domains = append(domains, d)
|
|
}
|
|
return domains
|
|
}
|
|
|
|
// AllUserAttrs returns all gsc* LDAP attribute names for user search
|
|
func (r *Registry) AllUserAttrs() []string {
|
|
attrs := make([]string, 0, len(r.attrs))
|
|
for name := range r.attrs {
|
|
attrs = append(attrs, name)
|
|
}
|
|
return attrs
|
|
}
|
|
|
|
// UserOCForDomain returns the auxiliary objectClass name for a user service domain
|
|
func (r *Registry) UserOCForDomain(domain string) string {
|
|
return r.domainOC[domain]
|
|
}
|
|
|
|
// RequiredOCsForAttrs determines which objectClasses are needed for a set of LDAP attributes
|
|
func (r *Registry) RequiredOCsForAttrs(ldapAttrNames []string) []string {
|
|
needed := make(map[string]bool)
|
|
attrSet := make(map[string]bool, len(ldapAttrNames))
|
|
for _, a := range ldapAttrNames {
|
|
attrSet[a] = true
|
|
}
|
|
|
|
for _, oc := range r.objectClasses {
|
|
if oc.Kind != "AUXILIARY" {
|
|
continue
|
|
}
|
|
for _, must := range oc.Must {
|
|
if attrSet[must] {
|
|
needed[oc.Name] = true
|
|
break
|
|
}
|
|
}
|
|
if needed[oc.Name] {
|
|
continue
|
|
}
|
|
for _, may := range oc.May {
|
|
if attrSet[may] {
|
|
needed[oc.Name] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make([]string, 0, len(needed))
|
|
for name := range needed {
|
|
result = append(result, name)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetObjectClass returns an objectClass definition by name
|
|
func (r *Registry) GetObjectClass(name string) *ObjectClassDef {
|
|
return r.objectClasses[name]
|
|
}
|
|
|
|
// LDAPValueToGo converts LDAP string values to Go typed values based on attribute type
|
|
func (r *Registry) LDAPValueToGo(attr *AttrDef, values []string) interface{} {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
|
|
switch attr.Type {
|
|
case AttrString, AttrDN:
|
|
return values[0]
|
|
case AttrStringMulti, AttrDNMulti:
|
|
return values
|
|
case AttrInt:
|
|
if v, err := strconv.Atoi(values[0]); err == nil {
|
|
return v
|
|
}
|
|
return values[0]
|
|
case AttrBool:
|
|
return strings.EqualFold(values[0], "TRUE")
|
|
case AttrTime:
|
|
// GeneralizedTime format: 20060102150405Z
|
|
if t, err := time.Parse("20060102150405Z", values[0]); err == nil {
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
return values[0]
|
|
default:
|
|
return values[0]
|
|
}
|
|
}
|
|
|
|
// GoValueToLDAP converts a Go value to LDAP string(s) based on attribute type
|
|
func (r *Registry) GoValueToLDAP(attr *AttrDef, value interface{}) ([]string, error) {
|
|
if value == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
switch attr.Type {
|
|
case AttrString, AttrDN:
|
|
s, ok := value.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("attribute %s expects string, got %T", attr.LDAPName, value)
|
|
}
|
|
return []string{s}, nil
|
|
|
|
case AttrStringMulti, AttrDNMulti:
|
|
switch v := value.(type) {
|
|
case []string:
|
|
return v, nil
|
|
case []interface{}:
|
|
result := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
s, ok := item.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("attribute %s expects string array, got %T in array", attr.LDAPName, item)
|
|
}
|
|
result = append(result, s)
|
|
}
|
|
return result, nil
|
|
default:
|
|
return nil, fmt.Errorf("attribute %s expects string array, got %T", attr.LDAPName, value)
|
|
}
|
|
|
|
case AttrInt:
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return []string{strconv.Itoa(int(v))}, nil
|
|
case int:
|
|
return []string{strconv.Itoa(v)}, nil
|
|
case string:
|
|
return []string{v}, nil
|
|
default:
|
|
return nil, fmt.Errorf("attribute %s expects int, got %T", attr.LDAPName, value)
|
|
}
|
|
|
|
case AttrBool:
|
|
switch v := value.(type) {
|
|
case bool:
|
|
if v {
|
|
return []string{"TRUE"}, nil
|
|
}
|
|
return []string{"FALSE"}, nil
|
|
case string:
|
|
return []string{strings.ToUpper(v)}, nil
|
|
default:
|
|
return nil, fmt.Errorf("attribute %s expects bool, got %T", attr.LDAPName, value)
|
|
}
|
|
|
|
case AttrTime:
|
|
s, ok := value.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("attribute %s expects time string, got %T", attr.LDAPName, value)
|
|
}
|
|
// Accept RFC3339 and convert to GeneralizedTime
|
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
return []string{t.UTC().Format("20060102150405Z")}, nil
|
|
}
|
|
// Already GeneralizedTime format
|
|
return []string{s}, nil
|
|
|
|
default:
|
|
s, ok := value.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("attribute %s: unsupported type %T", attr.LDAPName, value)
|
|
}
|
|
return []string{s}, nil
|
|
}
|
|
}
|
|
|
|
// GetEntityType returns an entity type definition by name
|
|
func (r *Registry) GetEntityType(name string) *EntityTypeDef {
|
|
return r.entityTypes[name]
|
|
}
|
|
|
|
// AllEntityTypes returns all registered entity type definitions
|
|
func (r *Registry) AllEntityTypes() map[string]*EntityTypeDef {
|
|
return r.entityTypes
|
|
}
|