generated from coulomb/repo-seed
feat: implement T16, T17 — Keycloak realm import transformer, LDIF generator
- 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>
This commit is contained in:
230
src/internal/migration/toldap/generator.go
Normal file
230
src/internal/migration/toldap/generator.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// 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
|
||||
}
|
||||
346
src/internal/migration/toldap/generator_test.go
Normal file
346
src/internal/migration/toldap/generator_test.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package toldap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/migration/lldapexport"
|
||||
"keycape/internal/migration/toldap"
|
||||
"keycape/internal/server/telemetry"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture emitter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type capEmitter struct {
|
||||
events []telemetry.Event
|
||||
}
|
||||
|
||||
func (c *capEmitter) Emit(_ context.Context, ev telemetry.Event) {
|
||||
c.events = append(c.events, ev)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func canonicalExport() *lldapexport.ExportResult {
|
||||
return &lldapexport.ExportResult{
|
||||
Users: []domain.User{
|
||||
{
|
||||
ID: "uid=alice,ou=users,dc=netkingdom,dc=local",
|
||||
Username: "alice",
|
||||
DisplayName: "Alice Example",
|
||||
Email: "alice@example.com",
|
||||
Enabled: true,
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
Groups: []domain.Group{
|
||||
{
|
||||
ID: "cn=admins,ou=groups,dc=netkingdom,dc=local",
|
||||
Name: "admins",
|
||||
Members: []string{"uid=alice,ou=users,dc=netkingdom,dc=local"},
|
||||
},
|
||||
},
|
||||
Memberships: []domain.Membership{
|
||||
{
|
||||
UserID: "uid=alice,ou=users,dc=netkingdom,dc=local",
|
||||
GroupID: "cn=admins,ou=groups,dc=netkingdom,dc=local",
|
||||
},
|
||||
},
|
||||
ExportedAt: time.Now().UTC(),
|
||||
ProfileVersion: "0.1",
|
||||
}
|
||||
}
|
||||
|
||||
func emptyExport() *lldapexport.ExportResult {
|
||||
return &lldapexport.ExportResult{
|
||||
Users: []domain.User{},
|
||||
Groups: []domain.Group{},
|
||||
Memberships: []domain.Membership{},
|
||||
ExportedAt: time.Now().UTC(),
|
||||
ProfileVersion: "0.1",
|
||||
}
|
||||
}
|
||||
|
||||
func newOpenLDAPGen(em telemetry.Emitter) *toldap.Generator {
|
||||
return toldap.New(toldap.Config{
|
||||
BaseDN: "dc=netkingdom,dc=local",
|
||||
Target: toldap.TargetOpenLDAP,
|
||||
}, em)
|
||||
}
|
||||
|
||||
func new389DSGen(em telemetry.Emitter) *toldap.Generator {
|
||||
return toldap.New(toldap.Config{
|
||||
BaseDN: "dc=netkingdom,dc=local",
|
||||
Target: toldap.Target389DS,
|
||||
}, em)
|
||||
}
|
||||
|
||||
func newADGen(em telemetry.Emitter) *toldap.Generator {
|
||||
return toldap.New(toldap.Config{
|
||||
BaseDN: "dc=netkingdom,dc=local",
|
||||
Target: toldap.TargetAD,
|
||||
}, em)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: User LDIF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerator_UserLDIF_RequiredAttributes(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newOpenLDAPGen(em)
|
||||
export := canonicalExport()
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
// Should contain user DN.
|
||||
if !strings.Contains(ldif, "dn: uid=alice,ou=users,dc=netkingdom,dc=local") {
|
||||
t.Error("LDIF missing user DN")
|
||||
}
|
||||
// Required objectClasses.
|
||||
if !strings.Contains(ldif, "objectClass: inetOrgPerson") {
|
||||
t.Error("LDIF missing objectClass: inetOrgPerson")
|
||||
}
|
||||
if !strings.Contains(ldif, "objectClass: person") {
|
||||
t.Error("LDIF missing objectClass: person")
|
||||
}
|
||||
if !strings.Contains(ldif, "objectClass: organizationalPerson") {
|
||||
t.Error("LDIF missing objectClass: organizationalPerson")
|
||||
}
|
||||
// uid attribute.
|
||||
if !strings.Contains(ldif, "uid: alice") {
|
||||
t.Error("LDIF missing uid attribute")
|
||||
}
|
||||
// cn attribute from DisplayName.
|
||||
if !strings.Contains(ldif, "cn: Alice Example") {
|
||||
t.Error("LDIF missing cn attribute")
|
||||
}
|
||||
// sn attribute (last name).
|
||||
if !strings.Contains(ldif, "sn: Example") {
|
||||
t.Error("LDIF missing sn attribute")
|
||||
}
|
||||
// mail attribute.
|
||||
if !strings.Contains(ldif, "mail: alice@example.com") {
|
||||
t.Error("LDIF missing mail attribute")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Group LDIF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerator_GroupLDIF_WithMemberDNs(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newOpenLDAPGen(em)
|
||||
export := canonicalExport()
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
// Should contain group DN.
|
||||
if !strings.Contains(ldif, "dn: cn=admins,ou=groups,dc=netkingdom,dc=local") {
|
||||
t.Error("LDIF missing group DN")
|
||||
}
|
||||
if !strings.Contains(ldif, "objectClass: groupOfNames") {
|
||||
t.Error("LDIF missing objectClass: groupOfNames")
|
||||
}
|
||||
if !strings.Contains(ldif, "cn: admins") {
|
||||
t.Error("LDIF missing group cn attribute")
|
||||
}
|
||||
// Member DN should be present.
|
||||
if !strings.Contains(ldif, "member: uid=alice,ou=users,dc=netkingdom,dc=local") {
|
||||
t.Error("LDIF missing member attribute")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Target differences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerator_Target_OpenLDAP_NoSAMAccountName(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newOpenLDAPGen(em)
|
||||
export := canonicalExport()
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(ldif, "sAMAccountName") {
|
||||
t.Error("OpenLDAP LDIF should not contain sAMAccountName")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerator_Target_389DS_HasNsUniqueIdIfAvailable(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := new389DSGen(em)
|
||||
export := canonicalExport()
|
||||
// Add nsUniqueId via LDAPAttributes.
|
||||
export.Users[0].LDAPAttributes = map[string]string{
|
||||
"nsUniqueId": "a1b2c3d4-1234-5678-abcd-ef0123456789",
|
||||
}
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(ldif, "nsUniqueId: a1b2c3d4-1234-5678-abcd-ef0123456789") {
|
||||
t.Error("389DS LDIF should include nsUniqueId when available in LDAPAttributes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerator_Target_389DS_NoNsUniqueIdWhenAbsent(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := new389DSGen(em)
|
||||
export := canonicalExport()
|
||||
// No LDAPAttributes set.
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(ldif, "nsUniqueId") {
|
||||
t.Error("389DS LDIF should not include nsUniqueId when not available in LDAPAttributes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerator_Target_AD_SAMAccountNamePresent(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newADGen(em)
|
||||
export := canonicalExport()
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(ldif, "sAMAccountName: alice") {
|
||||
t.Error("AD LDIF should contain sAMAccountName")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerator_Target_AD_UsesCNInDN(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newADGen(em)
|
||||
export := canonicalExport()
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
// AD uses cn= prefix instead of uid= in user DN.
|
||||
if !strings.Contains(ldif, "dn: cn=alice,ou=users,dc=netkingdom,dc=local") {
|
||||
t.Errorf("AD LDIF should use cn= in user DN; got:\n%s", ldif)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerator_Target_OpenLDAP_UsesUIDInDN(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newOpenLDAPGen(em)
|
||||
export := canonicalExport()
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(ldif, "dn: uid=alice,ou=users,dc=netkingdom,dc=local") {
|
||||
t.Errorf("OpenLDAP LDIF should use uid= in user DN; got:\n%s", ldif)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Empty export — structural entries only
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerator_EmptyExport_StructuralEntriesOnly(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newOpenLDAPGen(em)
|
||||
export := emptyExport()
|
||||
|
||||
ldif, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
// Should still produce the ou=users and ou=groups entries.
|
||||
if !strings.Contains(ldif, "dn: ou=users,dc=netkingdom,dc=local") {
|
||||
t.Error("LDIF missing structural ou=users entry")
|
||||
}
|
||||
if !strings.Contains(ldif, "dn: ou=groups,dc=netkingdom,dc=local") {
|
||||
t.Error("LDIF missing structural ou=groups entry")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Telemetry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerator_EmitsMigrationEvent(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newOpenLDAPGen(em)
|
||||
export := canonicalExport()
|
||||
|
||||
_, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, ev := range em.events {
|
||||
if ev.EventType == telemetry.EventMigration {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected migration_event telemetry, got none")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Validation called on output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerator_ValidationRunsOnOutput(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newOpenLDAPGen(em)
|
||||
export := canonicalExport()
|
||||
|
||||
// If validation is called, we expect no error for valid input.
|
||||
_, err := gen.Generate(export)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerator_ValidationFailsForInvalidData(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
gen := newOpenLDAPGen(em)
|
||||
|
||||
// User with empty username — would produce invalid LDIF.
|
||||
export := canonicalExport()
|
||||
export.Users[0].Username = ""
|
||||
export.Users[0].DisplayName = ""
|
||||
|
||||
_, err := gen.Generate(export)
|
||||
if err == nil {
|
||||
t.Error("Generate should return error for user with empty username (invalid LDIF)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user