key-cape/src/internal/migration/lldapexport/exporter.go

212 lines
7.5 KiB
Go
Raw Normal View History

// 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"
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 08:45:50 +02:00
"sort"
"time"
"gopkg.in/yaml.v3"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
"keycape/internal/validator"
)
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
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"
)
// ExportResult is the structured output of a single export run.
type ExportResult struct {
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
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
}
// 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)
}
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
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
}
// 3. Validate each user against the canonical LDAP schema.
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 08:45:50 +02:00
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)
}
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
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
}
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 08:45:50 +02:00
return memberships[i].UserID < memberships[j].UserID
})
// 5. Build ExportResult.
result := &ExportResult{
Users: validatedUsers,
Groups: groups,
Memberships: memberships,
ExportedAt: time.Now().UTC(),
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 08:45:50 +02:00
GroupEnumeration: enumeration,
ProfileVersion: "0.1",
IncompatibilityReport: incompatibilities,
}
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
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"
}
e.emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventMigration,
Endpoint: "lldap-export",
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 08:45:50 +02:00
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)
}
if err := os.WriteFile(outputFile, data, 0o644); err != nil {
return nil, fmt.Errorf("lldapexport: write file %q: %w", outputFile, err)
}
return result, nil
}
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
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
}