key-cape/src/cmd/lldap-export/main.go

117 lines
4 KiB
Go
Raw Normal View History

// lldap-export exports the LLDAP directory as a canonical YAML snapshot
// for use with the validator and migration tools.
package main
import (
"context"
"flag"
"fmt"
"os"
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
"strings"
"keycape/internal/adapters/lldap"
"keycape/internal/migration/lldapexport"
"keycape/internal/server/telemetry"
"keycape/internal/validator"
"github.com/rs/zerolog"
)
func main() {
// Flags.
url := flag.String("url", "ldap://localhost:389", "LLDAP server URL (ldap:// or ldaps://)")
bindDN := flag.String("bind-dn", "", "Service account bind DN (required)")
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
bindPW := flag.String("bind-pw", "", "Service account password (DEPRECATED: visible to any local user via ps; prefer KEYCAPE_LLDAP_BIND_PW or --bind-pw-file)")
bindPWFile := flag.String("bind-pw-file", "", "File containing the service account password")
baseDN := flag.String("base-dn", "", "LDAP search base DN (required)")
output := flag.String("output", "canonical-export.yaml", "Output file path")
tlsSkip := flag.Bool("tls-skip-verify", false, "Skip TLS certificate verification (dev only)")
flag.Parse()
if *bindDN == "" || *baseDN == "" {
fmt.Fprintln(os.Stderr, "lldap-export: --bind-dn and --base-dn are required")
flag.Usage()
os.Exit(1)
}
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
password, err := resolveBindPassword(*bindPW, *bindPWFile)
if err != nil {
fmt.Fprintf(os.Stderr, "lldap-export: %v\n", err)
os.Exit(1)
}
log := zerolog.New(os.Stderr).With().Timestamp().Logger()
emitter := telemetry.NewLogEmitter(log)
cfg := lldap.Config{
URL: *url,
BindDN: *bindDN,
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
BindPW: password,
BaseDN: *baseDN,
TLSSkipVerify: *tlsSkip,
}
repo := lldap.New(cfg)
exp := lldapexport.New(repo, validator.ModeProvisioning, emitter)
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
result, exportErr := exp.Export(context.Background(), *output)
if exportErr != nil {
fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", exportErr)
os.Exit(1)
}
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
fmt.Fprintf(os.Stdout, "Exported %d users, %d groups to %s (group enumeration: %s)\n",
len(result.Users), len(result.Groups), *output, result.GroupEnumeration)
if result.GroupEnumeration != lldapexport.EnumerationDirectory {
fmt.Fprintln(os.Stderr,
"lldap-export: groups were derived from user memberships; groups with no members are absent")
}
if len(result.IncompatibilityReport) > 0 {
fmt.Fprintln(os.Stderr, "Incompatibility report:")
for _, item := range result.IncompatibilityReport {
fmt.Fprintln(os.Stderr, " -", item)
}
os.Exit(2) // partial success: exported with warnings
}
}
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
// bindPasswordEnv is the preferred way to supply the service account password.
const bindPasswordEnv = "KEYCAPE_LLDAP_BIND_PW"
// resolveBindPassword takes the password from the environment, a file, or the
// deprecated flag, in that order.
//
// A password on argv is readable by any local user through ps and is captured by
// shell history and process accounting, which is why the flag is deprecated
// rather than merely discouraged (KEY-WP-0026). It still works, because the
// migration scripts and existing runbooks use it, but it warns.
func resolveBindPassword(flagValue, filePath string) (string, error) {
if env := os.Getenv(bindPasswordEnv); env != "" {
if flagValue != "" || filePath != "" {
return "", fmt.Errorf("%s is set as well as a password flag; supply exactly one", bindPasswordEnv)
}
return env, nil
}
if filePath != "" {
if flagValue != "" {
return "", fmt.Errorf("--bind-pw and --bind-pw-file are mutually exclusive")
}
contents, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("read --bind-pw-file: %w", err)
}
// A password file almost always ends in a newline from the editor or
// heredoc that wrote it; binding with it would fail confusingly.
return strings.TrimRight(string(contents), "\r\n"), nil
}
if flagValue != "" {
fmt.Fprintf(os.Stderr,
"lldap-export: warning: --bind-pw exposes the password to any local user via ps; prefer %s or --bind-pw-file\n",
bindPasswordEnv)
return flagValue, nil
}
return "", fmt.Errorf("no password supplied: set %s, or pass --bind-pw-file", bindPasswordEnv)
}