2026-03-13 02:08:03 +01:00
|
|
|
// 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"
|
2026-09-07 08:45:50 +02:00
|
|
|
"sort"
|
2026-03-13 02:08:03 +01:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
|
|
|
|
|
"keycape/internal/domain"
|
|
|
|
|
"keycape/internal/server/telemetry"
|
|
|
|
|
"keycape/internal/validator"
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-07 08:45:50 +02:00
|
|
|
// 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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-13 02:08:03 +01:00
|
|
|
// ExportResult is the structured output of a single export run.
|
|
|
|
|
type ExportResult struct {
|
2026-09-07 08:45:50 +02:00
|
|
|
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
|
2026-03-13 02:08:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 08:45:50 +02:00
|
|
|
// 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
|
2026-03-13 02:08:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Validate each user against the canonical LDAP schema.
|
2026-09-07 08:45:50 +02:00
|
|
|
incompatibilities := readErrs
|
2026-03-13 02:08:03 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 08:45:50 +02:00
|
|
|
// 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
|
2026-03-13 02:08:03 +01:00
|
|
|
}
|
2026-09-07 08:45:50 +02:00
|
|
|
return memberships[i].UserID < memberships[j].UserID
|
|
|
|
|
})
|
2026-03-13 02:08:03 +01:00
|
|
|
|
|
|
|
|
// 5. Build ExportResult.
|
|
|
|
|
result := &ExportResult{
|
|
|
|
|
Users: validatedUsers,
|
|
|
|
|
Groups: groups,
|
|
|
|
|
Memberships: memberships,
|
|
|
|
|
ExportedAt: time.Now().UTC(),
|
2026-09-07 08:45:50 +02:00
|
|
|
GroupEnumeration: enumeration,
|
2026-03-13 02:08:03 +01:00
|
|
|
ProfileVersion: "0.1",
|
|
|
|
|
IncompatibilityReport: incompatibilities,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 08:45:50 +02:00
|
|
|
// 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"
|
|
|
|
|
}
|
2026-03-13 02:08:03 +01:00
|
|
|
e.emitter.Emit(ctx, telemetry.Event{
|
|
|
|
|
Timestamp: time.Now().UTC(),
|
|
|
|
|
EventType: telemetry.EventMigration,
|
|
|
|
|
Endpoint: "lldap-export",
|
2026-09-07 08:45:50 +02:00
|
|
|
Result: outcome,
|
2026-03-13 02:08:03 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-09-07 08:45:50 +02:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|