generated from coulomb/repo-seed
- T16: canonical → Keycloak realm JSON (profile-safe: no identity brokering, implicit flow always false) - T17: canonical → LDIF for openldap/389ds/ad targets with pre-validation 27 migration tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
231 lines
5.6 KiB
Go
231 lines
5.6 KiB
Go
// 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=<id>,ou=users,<baseDN> 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
|
|
}
|