Reconcile packaging, bootstrap and migration credential handling
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 43s

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
This commit is contained in:
tegwick 2026-09-08 09:51:59 +02:00
parent dd8d5781e3
commit 3b72834bca
14 changed files with 405 additions and 15 deletions

View file

@ -95,7 +95,7 @@ func main() {
os.Exit(1)
}
if err := os.WriteFile(*outputFile, out, 0o644); err != nil {
if err := os.WriteFile(*outputFile, out, 0o600); err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: write %q: %v\n", *outputFile, err)
os.Exit(1)
}

View file

@ -7,6 +7,7 @@ import (
"flag"
"fmt"
"os"
"strings"
"keycape/internal/adapters/lldap"
"keycape/internal/migration/lldapexport"
@ -20,7 +21,8 @@ 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)")
bindPW := flag.String("bind-pw", "", "Service account password (required)")
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)")
@ -32,13 +34,19 @@ func main() {
os.Exit(1)
}
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,
BindPW: *bindPW,
BindPW: password,
BaseDN: *baseDN,
TLSSkipVerify: *tlsSkip,
}
@ -46,9 +54,9 @@ func main() {
repo := lldap.New(cfg)
exp := lldapexport.New(repo, validator.ModeProvisioning, emitter)
result, err := exp.Export(context.Background(), *output)
if err != nil {
fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", err)
result, exportErr := exp.Export(context.Background(), *output)
if exportErr != nil {
fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", exportErr)
os.Exit(1)
}
@ -68,3 +76,41 @@ func main() {
os.Exit(2) // partial success: exported with warnings
}
}
// 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)
}

View file

@ -0,0 +1,63 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// A password on argv is readable by any local user through ps, so the supported
// paths are the environment and a file; the flag survives for existing runbooks
// but is deprecated (KEY-WP-0026).
func TestResolveBindPassword(t *testing.T) {
t.Run("environment wins", func(t *testing.T) {
t.Setenv(bindPasswordEnv, "from-env")
got, err := resolveBindPassword("", "")
if err != nil || got != "from-env" {
t.Fatalf("got %q, err %v", got, err)
}
})
t.Run("file, trailing newline trimmed", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "pw")
// Editors and heredocs add one; binding with it would fail confusingly.
if err := os.WriteFile(path, []byte("from-file\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := resolveBindPassword("", path)
if err != nil || got != "from-file" {
t.Fatalf("got %q, err %v", got, err)
}
})
t.Run("deprecated flag still works", func(t *testing.T) {
got, err := resolveBindPassword("from-flag", "")
if err != nil || got != "from-flag" {
t.Fatalf("got %q, err %v", got, err)
}
})
t.Run("missing file is an error, not an empty password", func(t *testing.T) {
if _, err := resolveBindPassword("", filepath.Join(t.TempDir(), "absent")); err == nil {
t.Fatal("expected an error")
}
})
// Two sources means one of them is being silently ignored, and the operator
// cannot tell which bind was attempted.
t.Run("conflicting sources are rejected", func(t *testing.T) {
if _, err := resolveBindPassword("from-flag", "/tmp/whatever"); err == nil {
t.Fatal("expected --bind-pw and --bind-pw-file to conflict")
}
t.Setenv(bindPasswordEnv, "from-env")
if _, err := resolveBindPassword("from-flag", ""); err == nil {
t.Fatal("expected the environment and the flag to conflict")
}
})
t.Run("no password at all is an error", func(t *testing.T) {
if _, err := resolveBindPassword("", ""); err == nil {
t.Fatal("expected an error")
}
})
}

View file

@ -59,7 +59,7 @@ func main() {
os.Exit(1)
}
if err := os.WriteFile(*outputFile, []byte(ldif), 0o644); err != nil {
if err := os.WriteFile(*outputFile, []byte(ldif), 0o600); err != nil {
fmt.Fprintf(os.Stderr, "lldap-to-ldap: write %q: %v\n", *outputFile, err)
os.Exit(1)
}

View file

@ -151,7 +151,10 @@ func (e *Exporter) Export(ctx context.Context, outputFile string) (*ExportResult
if err != nil {
return nil, fmt.Errorf("lldapexport: marshal YAML: %w", err)
}
if err := os.WriteFile(outputFile, data, 0o644); err != nil {
// 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)
}