// Package toldap generates LDIF output from a canonical KeyCape export // (spec ยง7 โ€” migration contract, LLDAP โ†’ full LDAP path). package toldap import ( "context" "fmt" "strings" "time" "keycape/internal/domain" "keycape/internal/migration/lldapexport" "keycape/internal/server/telemetry" "keycape/internal/validator" ) // Target identifies the LDAP server implementation to generate for. type Target string const ( TargetOpenLDAP Target = "openldap" Target389DS Target = "389ds" TargetAD Target = "ad" ) // Config holds the generation parameters. type Config struct { BaseDN string Target Target } // Generator produces LDIF output from a canonical export. type Generator struct { cfg Config emitter telemetry.Emitter } // New creates a new Generator. func New(cfg Config, emitter telemetry.Emitter) *Generator { return &Generator{cfg: cfg, emitter: emitter} } // Generate produces LDIF content from a canonical export. // It validates the source data against the canonical schema before generating, // and returns an error if any user has missing required attributes. func (g *Generator) Generate(export *lldapexport.ExportResult) (string, error) { // Pre-validate the canonical data. snap := validator.Snapshot{ Users: export.Users, Groups: export.Groups, } report := validator.Validate(snap, validator.ModeMigration) if !report.Passed { msgs := collectFailures(report) return "", fmt.Errorf("toldap: canonical validation failed: %s", strings.Join(msgs, "; ")) } var sb strings.Builder // Write structural entries first. writeEntry(&sb, []string{ "dn: ou=users," + g.cfg.BaseDN, "objectClass: top", "objectClass: organizationalUnit", "ou: users", }) writeEntry(&sb, []string{ "dn: ou=groups," + g.cfg.BaseDN, "objectClass: top", "objectClass: organizationalUnit", "ou: groups", }) // Write user entries. for _, u := range export.Users { if err := g.writeUser(&sb, u); err != nil { return "", fmt.Errorf("toldap: user %q: %w", u.Username, err) } } // Write group entries. for _, grp := range export.Groups { g.writeGroup(&sb, grp) } ldif := sb.String() // Emit migration telemetry. g.emitter.Emit(context.Background(), telemetry.Event{ Timestamp: time.Now().UTC(), EventType: telemetry.EventMigration, Endpoint: "lldap-to-ldap", Result: "success", }) return ldif, nil } // --------------------------------------------------------------------------- // LDIF entry writers // --------------------------------------------------------------------------- func (g *Generator) writeUser(sb *strings.Builder, u domain.User) error { if u.Username == "" { return fmt.Errorf("username is required") } var dn string switch g.cfg.Target { case TargetAD: dn = "dn: cn=" + u.Username + ",ou=users," + g.cfg.BaseDN default: dn = "dn: uid=" + u.Username + ",ou=users," + g.cfg.BaseDN } firstName, lastName := splitDisplayName(u.DisplayName) cn := u.DisplayName if cn == "" { cn = u.Username } sn := lastName if sn == "" { sn = firstName } if sn == "" { sn = u.Username } attrs := []string{ dn, "objectClass: top", "objectClass: person", "objectClass: organizationalPerson", "objectClass: inetOrgPerson", "uid: " + u.Username, "cn: " + cn, "sn: " + sn, } if u.Email != "" { attrs = append(attrs, "mail: "+u.Email) } // Target-specific attributes. switch g.cfg.Target { case Target389DS: if nsUID, ok := u.LDAPAttributes["nsUniqueId"]; ok && nsUID != "" { attrs = append(attrs, "nsUniqueId: "+nsUID) } case TargetAD: attrs = append(attrs, "sAMAccountName: "+u.Username) } writeEntry(sb, attrs) return nil } func (g *Generator) writeGroup(sb *strings.Builder, grp domain.Group) { dn := "dn: cn=" + grp.Name + ",ou=groups," + g.cfg.BaseDN attrs := []string{ dn, "objectClass: top", "objectClass: groupOfNames", "cn: " + grp.Name, } for _, memberID := range grp.Members { // If the member ID is already a full DN, use it directly. // Otherwise build a uid=,ou=users, DN. memberDN := resolveMemberDN(memberID, g.cfg.BaseDN, g.cfg.Target) attrs = append(attrs, "member: "+memberDN) } writeEntry(sb, attrs) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // writeEntry writes LDIF lines for one entry followed by a blank line separator. func writeEntry(sb *strings.Builder, lines []string) { for _, line := range lines { sb.WriteString(line) sb.WriteByte('\n') } sb.WriteByte('\n') } // resolveMemberDN returns the full LDAP DN for a member. // If the memberID already contains a comma (i.e. is a DN), it is returned as-is. // Otherwise a DN is constructed from the username. func resolveMemberDN(memberID, baseDN string, target Target) string { if strings.Contains(memberID, ",") { // Already a full DN โ€” return as-is. return memberID } switch target { case TargetAD: return "cn=" + memberID + ",ou=users," + baseDN default: return "uid=" + memberID + ",ou=users," + baseDN } } // splitDisplayName splits a display name at the first space. func splitDisplayName(displayName string) (first, last string) { idx := strings.Index(displayName, " ") if idx < 0 { return displayName, "" } return displayName[:idx], displayName[idx+1:] } // collectFailures gathers all failed rule messages from a validator report. func collectFailures(report validator.Report) []string { var msgs []string for _, r := range report.Structural { if !r.Passed { msgs = append(msgs, r.Rule+": "+r.Message) } } for _, r := range report.Semantic { if !r.Passed { msgs = append(msgs, r.Rule+": "+r.Message) } } return msgs }