// Package lldapexport implements the LLDAP โ†’ canonical export tool (spec ยง7 โ€” migration contract). // It reads all users and groups from the LLDAP directory via a UserRepository, validates each // entry against the canonical LDAP schema, and writes a canonical-export.yaml snapshot. package lldapexport import ( "context" "fmt" "os" "time" "gopkg.in/yaml.v3" "keycape/internal/domain" "keycape/internal/server/telemetry" "keycape/internal/validator" ) // ExportResult is the structured output of a single export run. type ExportResult struct { Users []domain.User `yaml:"users"` Groups []domain.Group `yaml:"groups"` Memberships []domain.Membership `yaml:"memberships"` ExportedAt time.Time `yaml:"exportedAt"` ProfileVersion string `yaml:"profileVersion"` IncompatibilityReport []string `yaml:"incompatibilityReport,omitempty"` } // Exporter reads from a UserRepository, validates, and writes canonical-export.yaml. type Exporter struct { repo domain.UserRepository mode validator.Mode emitter telemetry.Emitter } // New creates a new Exporter. func New(repo domain.UserRepository, mode validator.Mode, emitter telemetry.Emitter) *Exporter { return &Exporter{ repo: repo, mode: mode, emitter: emitter, } } // Export reads all users and groups, validates them, builds ExportResult, // emits telemetry, and writes the YAML file to outputFile. // Validation failures are captured in IncompatibilityReport โ€” they are not fatal. func (e *Exporter) Export(ctx context.Context, outputFile string) (*ExportResult, error) { // 1. List all users from the repository. users, err := e.repo.ListUsers(ctx) if err != nil { return nil, fmt.Errorf("lldapexport: list users: %w", err) } // 2. List all groups by looking up groups for each user's DN. // Since UserRepository.LookupGroups takes a userDN, we collect groups // from all users and deduplicate by group ID. groupMap := make(map[string]domain.Group) for _, u := range users { userGroups, err := e.repo.LookupGroups(ctx, u.ID) if err != nil { // Non-fatal: log in incompatibility report. continue } for _, g := range userGroups { if _, seen := groupMap[g.ID]; !seen { groupMap[g.ID] = g } } } groups := make([]domain.Group, 0, len(groupMap)) for _, g := range groupMap { groups = append(groups, g) } // 3. Validate each user against the canonical LDAP schema. var incompatibilities []string validatedUsers := make([]domain.User, 0, len(users)) for _, u := range users { snap := validator.Snapshot{Users: []domain.User{u}} report := validator.Validate(snap, e.mode) if !report.Passed { for _, r := range report.Structural { if !r.Passed { incompatibilities = append(incompatibilities, fmt.Sprintf("user %q structural/%s: %s", u.Username, r.Rule, r.Message)) } } for _, r := range report.Semantic { if !r.Passed { incompatibilities = append(incompatibilities, fmt.Sprintf("user %q semantic/%s: %s", u.Username, r.Rule, r.Message)) } } } validatedUsers = append(validatedUsers, u) } // 4. Build memberships from group member lists. var memberships []domain.Membership for _, g := range groups { for _, memberID := range g.Members { memberships = append(memberships, domain.Membership{ UserID: memberID, GroupID: g.ID, }) } } // 5. Build ExportResult. result := &ExportResult{ Users: validatedUsers, Groups: groups, Memberships: memberships, ExportedAt: time.Now().UTC(), ProfileVersion: "0.1", IncompatibilityReport: incompatibilities, } // 6. Emit migration_event telemetry. e.emitter.Emit(ctx, telemetry.Event{ Timestamp: time.Now().UTC(), EventType: telemetry.EventMigration, Endpoint: "lldap-export", Result: "success", }) // 7. Write YAML to output file. data, err := yaml.Marshal(result) if err != nil { return nil, fmt.Errorf("lldapexport: marshal YAML: %w", err) } if err := os.WriteFile(outputFile, data, 0o644); err != nil { return nil, fmt.Errorf("lldapexport: write file %q: %w", outputFile, err) } return result, nil }