From fa27adbc77afdd7316a7333f3ac6a386b1aebf82 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 13 Mar 2026 02:13:04 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20T16,=20T17=20=E2=80=94=20Ke?= =?UTF-8?q?ycloak=20realm=20import=20transformer,=20LDIF=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/cmd/keycape-to-keycloak/main.go | 66 ++- src/cmd/lldap-to-ldap/main.go | 72 ++- .../migration/tokeycloak/transformer.go | 278 +++++++++++ .../migration/tokeycloak/transformer_test.go | 440 ++++++++++++++++++ src/internal/migration/toldap/generator.go | 230 +++++++++ .../migration/toldap/generator_test.go | 346 ++++++++++++++ 6 files changed, 1428 insertions(+), 4 deletions(-) create mode 100644 src/internal/migration/tokeycloak/transformer.go create mode 100644 src/internal/migration/tokeycloak/transformer_test.go create mode 100644 src/internal/migration/toldap/generator.go create mode 100644 src/internal/migration/toldap/generator_test.go diff --git a/src/cmd/keycape-to-keycloak/main.go b/src/cmd/keycape-to-keycloak/main.go index 4d0fbf7..5d076d1 100644 --- a/src/cmd/keycape-to-keycloak/main.go +++ b/src/cmd/keycape-to-keycloak/main.go @@ -3,11 +3,73 @@ package main import ( + "encoding/json" + "flag" "fmt" "os" + + "github.com/rs/zerolog" + "gopkg.in/yaml.v3" + + "keycape/internal/migration/lldapexport" + "keycape/internal/migration/tokeycloak" + "keycape/internal/server/telemetry" ) func main() { - fmt.Fprintln(os.Stderr, "keycape-to-keycloak: not yet implemented (T06+)") - os.Exit(1) + inputFile := flag.String("input", "", "Path to canonical-export.yaml (required)") + outputFile := flag.String("output", "keycloak-realm.json", "Path to write Keycloak realm import JSON") + realmName := flag.String("realm", "netkingdom", "Keycloak realm name") + issuer := flag.String("issuer", "", "OIDC issuer URL") + flag.Parse() + + if *inputFile == "" { + fmt.Fprintln(os.Stderr, "keycape-to-keycloak: -input is required") + flag.Usage() + os.Exit(1) + } + + data, err := os.ReadFile(*inputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "keycape-to-keycloak: read %q: %v\n", *inputFile, err) + os.Exit(1) + } + + var export lldapexport.ExportResult + if err := yaml.Unmarshal(data, &export); err != nil { + fmt.Fprintf(os.Stderr, "keycape-to-keycloak: parse YAML: %v\n", err) + os.Exit(1) + } + + log := zerolog.New(os.Stderr).With().Timestamp().Logger() + em := telemetry.NewLogEmitter(log) + tr := tokeycloak.New(tokeycloak.Config{ + RealmName: *realmName, + Issuer: *issuer, + }, em) + + realm, err := tr.Transform(&export) + if err != nil { + fmt.Fprintf(os.Stderr, "keycape-to-keycloak: transform: %v\n", err) + os.Exit(1) + } + + // Print validation report to stderr. + report := tr.ValidationReport(&export, realm) + for _, issue := range report { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", issue) + } + + out, err := json.MarshalIndent(realm, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "keycape-to-keycloak: marshal JSON: %v\n", err) + os.Exit(1) + } + + if err := os.WriteFile(*outputFile, out, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "keycape-to-keycloak: write %q: %v\n", *outputFile, err) + os.Exit(1) + } + + fmt.Printf("keycape-to-keycloak: wrote %s (%d bytes)\n", *outputFile, len(out)) } diff --git a/src/cmd/lldap-to-ldap/main.go b/src/cmd/lldap-to-ldap/main.go index 5b1b3d4..4737136 100644 --- a/src/cmd/lldap-to-ldap/main.go +++ b/src/cmd/lldap-to-ldap/main.go @@ -3,11 +3,79 @@ package main import ( + "flag" "fmt" "os" + + "github.com/rs/zerolog" + "gopkg.in/yaml.v3" + + "keycape/internal/migration/lldapexport" + "keycape/internal/migration/toldap" + "keycape/internal/server/telemetry" ) func main() { - fmt.Fprintln(os.Stderr, "lldap-to-ldap: not yet implemented (T06+)") - os.Exit(1) + inputFile := flag.String("input", "", "Path to canonical-export.yaml (required)") + outputFile := flag.String("output", "export.ldif", "Path to write LDIF output") + baseDN := flag.String("basedn", "dc=netkingdom,dc=local", "LDAP base DN") + targetStr := flag.String("target", "openldap", "LDAP target: openldap | 389ds | ad") + flag.Parse() + + if *inputFile == "" { + fmt.Fprintln(os.Stderr, "lldap-to-ldap: -input is required") + flag.Usage() + os.Exit(1) + } + + target, err := parseTarget(*targetStr) + if err != nil { + fmt.Fprintf(os.Stderr, "lldap-to-ldap: %v\n", err) + os.Exit(1) + } + + data, err := os.ReadFile(*inputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "lldap-to-ldap: read %q: %v\n", *inputFile, err) + os.Exit(1) + } + + var export lldapexport.ExportResult + if err := yaml.Unmarshal(data, &export); err != nil { + fmt.Fprintf(os.Stderr, "lldap-to-ldap: parse YAML: %v\n", err) + os.Exit(1) + } + + log := zerolog.New(os.Stderr).With().Timestamp().Logger() + em := telemetry.NewLogEmitter(log) + gen := toldap.New(toldap.Config{ + BaseDN: *baseDN, + Target: target, + }, em) + + ldif, err := gen.Generate(&export) + if err != nil { + fmt.Fprintf(os.Stderr, "lldap-to-ldap: generate: %v\n", err) + os.Exit(1) + } + + if err := os.WriteFile(*outputFile, []byte(ldif), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "lldap-to-ldap: write %q: %v\n", *outputFile, err) + os.Exit(1) + } + + fmt.Printf("lldap-to-ldap: wrote %s (%d bytes)\n", *outputFile, len(ldif)) +} + +func parseTarget(s string) (toldap.Target, error) { + switch s { + case "openldap": + return toldap.TargetOpenLDAP, nil + case "389ds": + return toldap.Target389DS, nil + case "ad": + return toldap.TargetAD, nil + default: + return "", fmt.Errorf("unknown target %q: must be one of openldap, 389ds, ad", s) + } } diff --git a/src/internal/migration/tokeycloak/transformer.go b/src/internal/migration/tokeycloak/transformer.go new file mode 100644 index 0000000..f6cfd14 --- /dev/null +++ b/src/internal/migration/tokeycloak/transformer.go @@ -0,0 +1,278 @@ +// Package tokeycloak transforms a canonical KeyCape export into a Keycloak realm +// import JSON file (spec §7 — migration contract, Keycloak expansion path). +package tokeycloak + +import ( + "context" + "strings" + "time" + + "keycape/internal/domain" + "keycape/internal/migration/lldapexport" + "keycape/internal/server/telemetry" +) + +// --------------------------------------------------------------------------- +// Keycloak realm import types +// --------------------------------------------------------------------------- + +// KeycloakRealm is the top-level realm import JSON structure. +type KeycloakRealm struct { + Realm string `json:"realm"` + DisplayName string `json:"displayName,omitempty"` + Enabled bool `json:"enabled"` + SsoSessionMaxLifespan int `json:"ssoSessionMaxLifespan,omitempty"` + DefaultSignatureAlgorithm string `json:"defaultSignatureAlgorithm,omitempty"` + IdentityProviders []interface{} `json:"identityProviders"` + Clients []KeycloakClient `json:"clients"` + Users []KeycloakUser `json:"users"` + Groups []KeycloakGroup `json:"groups"` + Roles KeycloakRoles `json:"roles"` + ClientScopes []KeycloakClientScope `json:"clientScopes"` +} + +// KeycloakClient represents a registered client in the Keycloak realm. +type KeycloakClient struct { + ClientID string `json:"clientId"` + Name string `json:"name,omitempty"` + Enabled bool `json:"enabled"` + PublicClient bool `json:"publicClient"` + StandardFlowEnabled bool `json:"standardFlowEnabled"` + ImplicitFlowEnabled bool `json:"implicitFlowEnabled"` + DirectAccessGrantsEnabled bool `json:"directAccessGrantsEnabled"` + RedirectUris []string `json:"redirectUris"` + DefaultClientScopes []string `json:"defaultClientScopes"` +} + +// KeycloakUser represents a user in the Keycloak realm. +type KeycloakUser struct { + Username string `json:"username"` + Email string `json:"email,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Enabled bool `json:"enabled"` + Groups []string `json:"groups,omitempty"` + Credentials []KeycloakCredential `json:"credentials,omitempty"` + Attributes map[string][]string `json:"attributes,omitempty"` +} + +// KeycloakCredential holds a single credential entry (e.g. hashed password placeholder). +type KeycloakCredential struct { + Type string `json:"type"` + Value string `json:"value"` + Temporary bool `json:"temporary"` +} + +// KeycloakGroup represents a user group in the Keycloak realm. +type KeycloakGroup struct { + Name string `json:"name"` + Path string `json:"path"` + Attributes map[string][]string `json:"attributes,omitempty"` +} + +// KeycloakRoles is the realm-level roles container. +type KeycloakRoles struct { + Realm []KeycloakRole `json:"realm"` +} + +// KeycloakRole represents a single realm role. +type KeycloakRole struct { + Name string `json:"name"` +} + +// KeycloakClientScope represents a client scope in the realm. +type KeycloakClientScope struct { + Name string `json:"name"` + Protocol string `json:"protocol"` +} + +// --------------------------------------------------------------------------- +// Transformer +// --------------------------------------------------------------------------- + +// Config holds realm-level configuration for the transformation. +type Config struct { + RealmName string + Issuer string +} + +// Transformer converts a canonical lldapexport.ExportResult into a KeycloakRealm. +type Transformer struct { + cfg Config + emitter telemetry.Emitter +} + +// New creates a new Transformer with the given configuration and telemetry emitter. +func New(cfg Config, emitter telemetry.Emitter) *Transformer { + return &Transformer{cfg: cfg, emitter: emitter} +} + +// Transform converts a canonical export to a Keycloak realm import. +// It maps users, groups, and emits migration_event telemetry. +// Clients default to an empty slice; use TransformWithClients to include them. +func (t *Transformer) Transform(export *lldapexport.ExportResult) (*KeycloakRealm, error) { + return t.TransformWithClients(export, nil) +} + +// TransformWithClients converts a canonical export plus an explicit client list +// into a Keycloak realm import structure. +func (t *Transformer) TransformWithClients(export *lldapexport.ExportResult, clients []domain.Client) (*KeycloakRealm, error) { + realm := &KeycloakRealm{ + Realm: t.cfg.RealmName, + Enabled: true, + IdentityProviders: []interface{}{}, + } + + // ProfileVersion "0.1" → RS256. + if export.ProfileVersion == "0.1" { + realm.DefaultSignatureAlgorithm = "RS256" + } + + // Map users. + realm.Users = make([]KeycloakUser, 0, len(export.Users)) + for _, u := range export.Users { + realm.Users = append(realm.Users, mapUser(u)) + } + + // Map groups. + realm.Groups = make([]KeycloakGroup, 0, len(export.Groups)) + for _, g := range export.Groups { + realm.Groups = append(realm.Groups, mapGroup(g)) + } + + // Map clients. + realm.Clients = make([]KeycloakClient, 0, len(clients)) + for _, c := range clients { + realm.Clients = append(realm.Clients, mapClient(c)) + } + + // Roles and scopes — empty in base migration; can be extended. + realm.Roles = KeycloakRoles{Realm: []KeycloakRole{}} + realm.ClientScopes = []KeycloakClientScope{} + + // Emit migration telemetry. + t.emitter.Emit(context.Background(), telemetry.Event{ + Timestamp: time.Now().UTC(), + EventType: telemetry.EventMigration, + Endpoint: "keycape-to-keycloak", + Result: "success", + }) + + return realm, nil +} + +// ValidationReport compares a canonical export against a produced Keycloak realm +// and returns a list of incompatibility descriptions. +// An empty slice means the import is consistent with the canonical data. +func (t *Transformer) ValidationReport(export *lldapexport.ExportResult, realm *KeycloakRealm) []string { + var issues []string + + // Any pre-existing incompatibilities from the canonical export propagate. + for _, inc := range export.IncompatibilityReport { + issues = append(issues, "canonical export incompatibility: "+inc) + } + + // User count must match. + if len(realm.Users) != len(export.Users) { + issues = append(issues, "user count mismatch: canonical has "+ + itoa(len(export.Users))+" users but realm has "+itoa(len(realm.Users))) + } + + // Group count must match. + if len(realm.Groups) != len(export.Groups) { + issues = append(issues, "group count mismatch: canonical has "+ + itoa(len(export.Groups))+" groups but realm has "+itoa(len(realm.Groups))) + } + + // Identity providers must be empty per the NetKingdom IAM profile. + if len(realm.IdentityProviders) != 0 { + issues = append(issues, "identity providers must be empty per NetKingdom IAM profile") + } + + return issues +} + +// --------------------------------------------------------------------------- +// Mapping helpers +// --------------------------------------------------------------------------- + +func mapUser(u domain.User) KeycloakUser { + ku := KeycloakUser{ + Username: u.Username, + Email: u.Email, + Enabled: u.Enabled, + } + + // Split DisplayName at first space → FirstName + LastName. + ku.FirstName, ku.LastName = splitDisplayName(u.DisplayName) + + // Convert group names to Keycloak paths: "/groupname". + if len(u.Groups) > 0 { + ku.Groups = make([]string, len(u.Groups)) + for i, g := range u.Groups { + ku.Groups[i] = "/" + g + } + } + + return ku +} + +func mapGroup(g domain.Group) KeycloakGroup { + return KeycloakGroup{ + Name: g.Name, + Path: "/" + g.Name, + } +} + +func mapClient(c domain.Client) KeycloakClient { + kc := KeycloakClient{ + ClientID: c.ClientID, + Name: c.DisplayName, + Enabled: true, + PublicClient: c.ClientType == "public", + StandardFlowEnabled: true, // authorization_code always enabled + ImplicitFlowEnabled: false, // never — per NetKingdom IAM profile + DirectAccessGrantsEnabled: false, // never — per NetKingdom IAM profile + RedirectUris: c.RedirectURIs, + DefaultClientScopes: c.AllowedScopes, + } + if kc.RedirectUris == nil { + kc.RedirectUris = []string{} + } + if kc.DefaultClientScopes == nil { + kc.DefaultClientScopes = []string{} + } + return kc +} + +// splitDisplayName splits a display name at the first space. +// "Alice Liddell" → ("Alice", "Liddell") +// "Bob" → ("Bob", "") +// "Alice M Smith" → ("Alice", "M Smith") +func splitDisplayName(displayName string) (first, last string) { + idx := strings.Index(displayName, " ") + if idx < 0 { + return displayName, "" + } + return displayName[:idx], displayName[idx+1:] +} + +// itoa converts an int to its decimal string representation without importing strconv. +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + buf := make([]byte, 0, 10) + for n > 0 { + buf = append([]byte{byte('0' + n%10)}, buf...) + n /= 10 + } + if neg { + buf = append([]byte{'-'}, buf...) + } + return string(buf) +} diff --git a/src/internal/migration/tokeycloak/transformer_test.go b/src/internal/migration/tokeycloak/transformer_test.go new file mode 100644 index 0000000..249c9df --- /dev/null +++ b/src/internal/migration/tokeycloak/transformer_test.go @@ -0,0 +1,440 @@ +package tokeycloak_test + +import ( + "context" + "testing" + "time" + + "keycape/internal/domain" + "keycape/internal/migration/lldapexport" + "keycape/internal/migration/tokeycloak" + "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 Liddell", + Email: "alice@example.com", + Enabled: true, + Groups: []string{"admins"}, + }, + { + ID: "uid=bob,ou=users,dc=netkingdom,dc=local", + Username: "bob", + DisplayName: "Bob", + Email: "bob@example.com", + Enabled: false, + Groups: []string{}, + }, + }, + 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 publicClient() domain.Client { + return domain.Client{ + ClientID: "webapp", + DisplayName: "Web Application", + RedirectURIs: []string{"https://app.example.com/callback"}, + AllowedScopes: []string{"openid", "profile", "email"}, + GrantTypes: []string{"authorization_code"}, + ClientType: "public", + } +} + +func confidentialClient() domain.Client { + return domain.Client{ + ClientID: "backend-svc", + DisplayName: "Backend Service", + RedirectURIs: []string{"https://svc.example.com/callback"}, + AllowedScopes: []string{"openid", "profile"}, + GrantTypes: []string{"authorization_code"}, + ClientType: "confidential", + SecretRef: "vault:secret/backend-svc", + } +} + +func newTransformer(em telemetry.Emitter) *tokeycloak.Transformer { + return tokeycloak.New(tokeycloak.Config{ + RealmName: "netkingdom", + Issuer: "https://auth.netkingdom.local", + }, em) +} + +// --------------------------------------------------------------------------- +// Tests: User mapping +// --------------------------------------------------------------------------- + +func TestTransformer_UserMapping_UsernameAndEmail(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + if len(realm.Users) != 2 { + t.Fatalf("expected 2 users, got %d", len(realm.Users)) + } + + alice := realm.Users[0] + if alice.Username != "alice" { + t.Errorf("username: want %q, got %q", "alice", alice.Username) + } + if alice.Email != "alice@example.com" { + t.Errorf("email: want %q, got %q", "alice@example.com", alice.Email) + } + if !alice.Enabled { + t.Error("alice should be enabled") + } + + bob := realm.Users[1] + if bob.Enabled { + t.Error("bob should be disabled") + } +} + +func TestTransformer_UserMapping_DisplayNameSplit(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + alice := realm.Users[0] + if alice.FirstName != "Alice" { + t.Errorf("firstName: want %q, got %q", "Alice", alice.FirstName) + } + if alice.LastName != "Liddell" { + t.Errorf("lastName: want %q, got %q", "Liddell", alice.LastName) + } +} + +func TestTransformer_UserMapping_DisplayNameSingleWord(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + // "Bob" has a single-word DisplayName — should land in FirstName only. + bob := realm.Users[1] + if bob.FirstName != "Bob" { + t.Errorf("firstName: want %q, got %q", "Bob", bob.FirstName) + } + if bob.LastName != "" { + t.Errorf("lastName: want empty, got %q", bob.LastName) + } +} + +func TestTransformer_UserMapping_GroupPaths(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + alice := realm.Users[0] + if len(alice.Groups) != 1 { + t.Fatalf("expected 1 group for alice, got %d", len(alice.Groups)) + } + if alice.Groups[0] != "/admins" { + t.Errorf("group path: want %q, got %q", "/admins", alice.Groups[0]) + } +} + +// --------------------------------------------------------------------------- +// Tests: Group mapping +// --------------------------------------------------------------------------- + +func TestTransformer_GroupMapping_NameAndPath(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + if len(realm.Groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(realm.Groups)) + } + g := realm.Groups[0] + if g.Name != "admins" { + t.Errorf("group name: want %q, got %q", "admins", g.Name) + } + if g.Path != "/admins" { + t.Errorf("group path: want %q, got %q", "/admins", g.Path) + } +} + +// --------------------------------------------------------------------------- +// Tests: Client mapping +// --------------------------------------------------------------------------- + +func TestTransformer_ClientMapping_PublicClient(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + export.Users = nil + export.Groups = nil + + // Inject clients via a wrapper export that carries them. + // The Transformer Transform method takes ExportResult + separate clients. + // We test via TransformWithClients. + realm, err := tr.TransformWithClients(export, []domain.Client{publicClient()}) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + if len(realm.Clients) != 1 { + t.Fatalf("expected 1 client, got %d", len(realm.Clients)) + } + c := realm.Clients[0] + if c.ClientID != "webapp" { + t.Errorf("clientId: want %q, got %q", "webapp", c.ClientID) + } + if !c.PublicClient { + t.Error("publicClient should be true for ClientType=public") + } + if !c.StandardFlowEnabled { + t.Error("standardFlowEnabled should always be true") + } + if c.ImplicitFlowEnabled { + t.Error("implicitFlowEnabled must always be false") + } + if c.DirectAccessGrantsEnabled { + t.Error("directAccessGrantsEnabled must always be false") + } +} + +func TestTransformer_ClientMapping_ConfidentialClient(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + export.Users = nil + export.Groups = nil + + realm, err := tr.TransformWithClients(export, []domain.Client{confidentialClient()}) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + if len(realm.Clients) != 1 { + t.Fatalf("expected 1 client, got %d", len(realm.Clients)) + } + c := realm.Clients[0] + if c.PublicClient { + t.Error("publicClient should be false for ClientType=confidential") + } +} + +func TestTransformer_ClientMapping_AllowedScopesBecomesDefaultClientScopes(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + export.Users = nil + export.Groups = nil + + realm, err := tr.TransformWithClients(export, []domain.Client{publicClient()}) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + c := realm.Clients[0] + if len(c.DefaultClientScopes) != 3 { + t.Errorf("defaultClientScopes: want 3, got %d", len(c.DefaultClientScopes)) + } +} + +func TestTransformer_ClientMapping_ImplicitFlowAlwaysFalse(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + export.Users = nil + export.Groups = nil + + // Even if GrantTypes contains "implicit", Keycloak output must have ImplicitFlowEnabled=false. + weirdClient := publicClient() + weirdClient.GrantTypes = append(weirdClient.GrantTypes, "implicit") + + realm, err := tr.TransformWithClients(export, []domain.Client{weirdClient}) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + if realm.Clients[0].ImplicitFlowEnabled { + t.Error("implicitFlowEnabled must always be false per NetKingdom IAM profile") + } +} + +// --------------------------------------------------------------------------- +// Tests: Identity providers always empty +// --------------------------------------------------------------------------- + +func TestTransformer_IdentityProviders_AlwaysEmpty(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + if len(realm.IdentityProviders) != 0 { + t.Errorf("identityProviders: want empty slice, got %d entries", len(realm.IdentityProviders)) + } +} + +// --------------------------------------------------------------------------- +// Tests: ProfileVersion → DefaultSignatureAlgorithm +// --------------------------------------------------------------------------- + +func TestTransformer_ProfileVersion_RS256(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() // ProfileVersion "0.1" + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + if realm.DefaultSignatureAlgorithm != "RS256" { + t.Errorf("defaultSignatureAlgorithm: want %q, got %q", "RS256", realm.DefaultSignatureAlgorithm) + } +} + +// --------------------------------------------------------------------------- +// Tests: Realm metadata +// --------------------------------------------------------------------------- + +func TestTransformer_RealmMetadata(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + if realm.Realm != "netkingdom" { + t.Errorf("realm: want %q, got %q", "netkingdom", realm.Realm) + } + if !realm.Enabled { + t.Error("realm should be enabled") + } +} + +// --------------------------------------------------------------------------- +// Tests: Telemetry +// --------------------------------------------------------------------------- + +func TestTransformer_EmitsMigrationEvent(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + _, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform 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: ValidationReport +// --------------------------------------------------------------------------- + +func TestTransformer_ValidationReport_IncompatibleExport(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + // Add an incompatibility entry to simulate unmappable data. + export.IncompatibilityReport = []string{"user \"broken\" structural/required_attributes_present: missing displayName"} + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + report := tr.ValidationReport(export, realm) + if len(report) == 0 { + t.Error("expected validation report entries for export with incompatibilities, got none") + } +} + +func TestTransformer_ValidationReport_CleanExport(t *testing.T) { + em := &capEmitter{} + tr := newTransformer(em) + export := canonicalExport() + + realm, err := tr.Transform(export) + if err != nil { + t.Fatalf("Transform returned error: %v", err) + } + + report := tr.ValidationReport(export, realm) + if len(report) != 0 { + t.Errorf("expected no validation issues for clean export, got: %v", report) + } +} diff --git a/src/internal/migration/toldap/generator.go b/src/internal/migration/toldap/generator.go new file mode 100644 index 0000000..98963f3 --- /dev/null +++ b/src/internal/migration/toldap/generator.go @@ -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=,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 +} diff --git a/src/internal/migration/toldap/generator_test.go b/src/internal/migration/toldap/generator_test.go new file mode 100644 index 0000000..be8b63b --- /dev/null +++ b/src/internal/migration/toldap/generator_test.go @@ -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)") + } +}