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

215 lines
7.7 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)
}
Reconcile packaging, bootstrap and migration credential handling Closes gap G09: five loosely related defects. lldap-export took the service account password on argv, where ps exposes it to any local user and shell history and process accounting capture it. It now prefers KEYCAPE_LLDAP_BIND_PW or --bind-pw-file; --bind-pw still works but warns, deprecated rather than removed because existing runbooks use it and breaking them silently would be worse than one more cycle of exposure. Conflicting sources are rejected instead of silently ranked, since an operator otherwise cannot tell which bind was attempted. Both migration scripts pass the password by environment now. The canonical export, generated LDIF and Keycloak realm were written 0644. None carries credential material, but the snapshot is every username, display name, email and group membership in the estate, and it tends to land in /tmp. All three are 0600. The image packaged keycape alone, so the validator and migration binaries needed a Go toolchain on the host -- which defeats shipping an image for the cutover work they exist to support. All five ship; the issuer stays the entrypoint. Verified by building the image and running each binary inside it. The publish workflow named 92.205.130.254:32166 while the cluster runs forgejo.coulomb.social/coulomb/key-cape. It now defaults to the recorded name and stays overridable by a repository variable. This repository cannot verify that the runner resolves that hostname or that the registry credentials are valid for it; if the next publish fails, set the REGISTRY variable back to the address. docker-compose.dev.yml mounts a private key and Authelia material that are correctly absent from the checkout. scripts/bootstrap-dev.sh generates them locally under a restrictive umask rather than chmodding afterwards, so the key is never briefly world-readable. Everything it writes is git-ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:51:59 +02:00
// 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
}
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
}