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:
@@ -3,11 +3,73 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"keycape/internal/migration/lldapexport"
|
||||||
|
"keycape/internal/migration/tokeycloak"
|
||||||
|
"keycape/internal/server/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Fprintln(os.Stderr, "keycape-to-keycloak: not yet implemented (T06+)")
|
inputFile := flag.String("input", "", "Path to canonical-export.yaml (required)")
|
||||||
os.Exit(1)
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,79 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"keycape/internal/migration/lldapexport"
|
||||||
|
"keycape/internal/migration/toldap"
|
||||||
|
"keycape/internal/server/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Fprintln(os.Stderr, "lldap-to-ldap: not yet implemented (T06+)")
|
inputFile := flag.String("input", "", "Path to canonical-export.yaml (required)")
|
||||||
os.Exit(1)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
278
src/internal/migration/tokeycloak/transformer.go
Normal file
278
src/internal/migration/tokeycloak/transformer.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
440
src/internal/migration/tokeycloak/transformer_test.go
Normal file
440
src/internal/migration/tokeycloak/transformer_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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