139 lines
4.2 KiB
Go
139 lines
4.2 KiB
Go
|
|
// 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
|
||
|
|
}
|