// 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" "sort" "time" "gopkg.in/yaml.v3" "keycape/internal/domain" "keycape/internal/server/telemetry" "keycape/internal/validator" ) // Group enumeration modes recorded on an ExportResult. They are the export's // own statement about how much of the directory it could see. const ( // EnumerationDirectory means every group was read from the directory, // so groups nobody belongs to are present in the snapshot. EnumerationDirectory = "directory" // EnumerationMembershipDerived means groups were discovered by walking // each user's memberships. Empty and unreferenced groups are absent by // construction; such a snapshot is not a complete directory export. EnumerationMembershipDerived = "membership-derived" ) // 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"` // GroupEnumeration is EnumerationDirectory or EnumerationMembershipDerived. // It is written unconditionally: a consumer must not have to infer from an // absent field whether the snapshot covers the whole directory. GroupEnumeration string `yaml:"groupEnumeration"` ProfileVersion string `yaml:"profileVersion"` IncompatibilityReport []string `yaml:"incompatibilityReport,omitempty"` } // Complete reports whether the run enumerated the whole directory and read // every entry it attempted. Only a complete run may be described as a full // directory snapshot. func (r *ExportResult) Complete() bool { return r.GroupEnumeration == EnumerationDirectory && len(r.IncompatibilityReport) == 0 } // 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. Enumerate groups. A directory-wide enumeration is the only way to // see groups nobody belongs to, so prefer it and fall back to walking // user memberships only when the repository cannot provide one. groups, memberships, enumeration, readErrs, err := e.enumerateGroups(ctx, users) if err != nil { return nil, err } // 3. Validate each user against the canonical LDAP schema. incompatibilities := readErrs 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. Order every collection on a stable key so an unchanged directory // exports byte-identically across runs. sort.Slice(validatedUsers, func(i, j int) bool { return validatedUsers[i].ID < validatedUsers[j].ID }) sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) sort.Slice(memberships, func(i, j int) bool { if memberships[i].GroupID != memberships[j].GroupID { return memberships[i].GroupID < memberships[j].GroupID } return memberships[i].UserID < memberships[j].UserID }) // 5. Build ExportResult. result := &ExportResult{ Users: validatedUsers, Groups: groups, Memberships: memberships, ExportedAt: time.Now().UTC(), GroupEnumeration: enumeration, ProfileVersion: "0.1", IncompatibilityReport: incompatibilities, } // 6. Emit migration_event telemetry. A snapshot that skipped an entry or // could not enumerate the directory is reported as partial — calling it // a success is what let an incomplete export pass unnoticed. outcome := "partial" if result.Complete() { outcome = "success" } e.emitter.Emit(ctx, telemetry.Event{ Timestamp: time.Now().UTC(), EventType: telemetry.EventMigration, Endpoint: "lldap-export", Result: outcome, }) // 7. Write YAML to output file. data, err := yaml.Marshal(result) if err != nil { return nil, fmt.Errorf("lldapexport: marshal YAML: %w", err) } // 0600: the snapshot is a directory dump -- every username, display name, // email and group membership in the estate. Not credential material, but not // world-readable either (KEY-WP-0026). if err := os.WriteFile(outputFile, data, 0o600); err != nil { return nil, fmt.Errorf("lldapexport: write file %q: %w", outputFile, err) } return result, nil } // enumerateGroups returns the directory's groups and memberships, the // enumeration mode that produced them, and any read failures to record in the // incompatibility report. // // A failed directory enumeration is fatal: the caller asked for the whole // directory and cannot be handed a silently smaller one. A failed per-user // lookup in the fallback path is reported rather than fatal, because the // fallback is already known to be incomplete and the report is where that is // stated. func (e *Exporter) enumerateGroups(ctx context.Context, users []domain.User) ( groups []domain.Group, memberships []domain.Membership, enumeration string, readErrs []string, err error, ) { if lister, ok := e.repo.(domain.GroupLister); ok { groups, err = lister.ListGroups(ctx) if err != nil { return nil, nil, "", nil, fmt.Errorf("lldapexport: list groups: %w", err) } for _, g := range groups { for _, memberID := range g.Members { memberships = append(memberships, domain.Membership{UserID: memberID, GroupID: g.ID}) } } return groups, memberships, EnumerationDirectory, nil, nil } readErrs = append(readErrs, "export incomplete: the directory adapter cannot enumerate groups, "+ "so groups with no members are absent from this snapshot") groupMap := make(map[string]domain.Group) for _, u := range users { userGroups, lookupErr := e.repo.LookupGroups(ctx, u.ID) if lookupErr != nil { readErrs = append(readErrs, fmt.Sprintf("user %q group lookup failed, memberships omitted: %v", u.Username, lookupErr)) continue } for _, g := range userGroups { if _, seen := groupMap[g.ID]; !seen { groupMap[g.ID] = g } // Derive the membership from the pair actually observed. The // group's own Members list is not populated on this path. memberships = append(memberships, domain.Membership{UserID: u.ID, GroupID: g.ID}) } } groups = make([]domain.Group, 0, len(groupMap)) for _, g := range groupMap { groups = append(groups, g) } return groups, memberships, EnumerationMembershipDerived, readErrs, nil }