From e2b5fa1442de04ec30c370915732331e8eee4ae5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 10:24:22 +0100 Subject: [PATCH] KEY-WP-0046: directory bind password from KEYCAPE_LLDAP_BIND_PW config.Load uses the variable when lldap.bindPW is empty, accepts equal values (migration, logged as redundant) and refuses differing values with an error that names neither. check-config and identity-facts report the source, never the value. For net-kingdom NK-WP-0056-T04 (ESO-delivered svc-keycape bind). Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 896183@bnt-lap001 Assistant-Session: 3a18c01a-3205-4d02-94db-9efff25649a2 --- WORK-RECORDS.md | 3 ++ docs/operations.md | 10 ++++ src/cmd/keycape/checkconfig.go | 4 +- src/cmd/keycape/identityfacts.go | 4 +- src/cmd/keycape/main.go | 3 ++ src/internal/config/config.go | 43 ++++++++++++++++ src/internal/config/config_test.go | 46 +++++++++++++++++ ...KEY-WP-0046-directory-bind-password-env.md | 49 +++++++++++++++++++ 8 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 workplans/KEY-WP-0046-directory-bind-password-env.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 485a371..24b49e2 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -56,6 +56,7 @@ | workplan | KEY-WP-0043 | proposed | — | workplans/KEY-WP-0043-per-run-agent-identity.md | | workplan | KEY-WP-0044 | proposed | — | workplans/KEY-WP-0044-audit-correlation-and-intake.md | | workplan | KEY-WP-0045 | active | — | workplans/KEY-WP-0045-service-identity-facts-export.md | +| workplan | KEY-WP-0046 | active | — | workplans/KEY-WP-0046-directory-bind-password-env.md | | task | ADHOC-2026-09-05-T01 | done | — | workplans/ADHOC-2026-09-05.md | | task | ADHOC-2026-09-07-T01 | done | — | workplans/ADHOC-2026-09-07.md | | task | ADHOC-2026-09-27-T01 | done | — | workplans/ADHOC-2026-09-27.md | @@ -239,4 +240,6 @@ | task | KEY-WP-0045-T01 | done | — | workplans/KEY-WP-0045-service-identity-facts-export.md | | task | KEY-WP-0045-T02 | wait | — | workplans/KEY-WP-0045-service-identity-facts-export.md | | task | KEY-WP-0045-T03 | todo | — | workplans/KEY-WP-0045-service-identity-facts-export.md | +| task | KEY-WP-0046-T01 | done | — | workplans/KEY-WP-0046-directory-bind-password-env.md | +| task | KEY-WP-0046-T02 | todo | — | workplans/KEY-WP-0046-directory-bind-password-env.md | | intake | KEY-IN-0001 | closed | — | intakes/intakes.md | diff --git a/docs/operations.md b/docs/operations.md index c4d8381..d46e72d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -249,6 +249,16 @@ No resource-efficiency or throughput bounds are asserted here. Nothing in this repository benchmarks KeyCape, so any figure would be invention. Measure it in your own deployment before sizing against it. +## Directory bind password from the environment + +`KEYCAPE_LLDAP_BIND_PW` supplies the LLDAP bind password, so a deployment can deliver +it from its own Secret key (`valueFrom.secretKeyRef`) instead of `lldap.bindPW` in +`config.yaml`. If only the variable is set it is used. If both are set they must be +equal (the migration state; the server logs that `bindPW` is redundant); different +values refuse startup, and the error names neither value. `keycape check-config` reports +`lldap_bind_pw_source` (`config`, `env`, `env-and-config` or empty) and +`keycape identity-facts` reports `password_source`. Neither command prints the value. + ## Account recovery and browser sign-out Set `KEYCAPE_ACCOUNT_PORTAL_URL=https://users.coulomb.social` and diff --git a/src/cmd/keycape/checkconfig.go b/src/cmd/keycape/checkconfig.go index 5edcfd7..8be6331 100644 --- a/src/cmd/keycape/checkconfig.go +++ b/src/cmd/keycape/checkconfig.go @@ -22,6 +22,8 @@ type checkConfigReport struct { Errors []string `json:"errors"` Clients int `json:"clients"` RequiredEnv []string `json:"required_env"` + // BindPWSource says where the directory bind password comes from, never the value. + BindPWSource string `json:"lldap_bind_pw_source"` } // runCheckConfig applies every config check the server makes at startup that @@ -49,6 +51,7 @@ func runCheckConfig(args []string, out io.Writer) int { return writeReport(out, report) } report.Clients = len(cfg.Clients) + report.BindPWSource = cfg.LLDAPBindPWSource report.Errors = append(report.Errors, config.ValidateConfig(cfg)...) if cfg.TokenLifetime != "" { if _, err := time.ParseDuration(cfg.TokenLifetime); err != nil { @@ -93,4 +96,3 @@ func writeReport(out io.Writer, report checkConfigReport) int { } return 1 } - diff --git a/src/cmd/keycape/identityfacts.go b/src/cmd/keycape/identityfacts.go index 8e86bd0..6be24f6 100644 --- a/src/cmd/keycape/identityfacts.go +++ b/src/cmd/keycape/identityfacts.go @@ -55,6 +55,7 @@ type directoryBindFacts struct { BindDN string `json:"bind_dn"` BaseDN string `json:"base_dn"` PasswordInConfig bool `json:"password_in_config"` + PasswordSource string `json:"password_source"` BindIsDirectoryAdmin bool `json:"bind_is_directory_admin"` } @@ -163,7 +164,8 @@ func buildIdentityFacts(cfg *config.Config) identityFacts { URL: cfg.LLDAP.URL, BindDN: cfg.LLDAP.BindDN, BaseDN: cfg.LLDAP.BaseDN, - PasswordInConfig: cfg.LLDAP.BindPW != "", + PasswordInConfig: cfg.LLDAPBindPWSource == config.BindPWSourceConfig || cfg.LLDAPBindPWSource == config.BindPWSourceBoth, + PasswordSource: cfg.LLDAPBindPWSource, BindIsDirectoryAdmin: strings.HasPrefix(bind, "uid=admin,") || strings.HasPrefix(bind, "cn=admin,"), } if facts.DirectoryBind.BindIsDirectoryAdmin { diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 220464b..63b2f09 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -75,6 +75,9 @@ func main() { log.Error().Err(err).Msg("failed to load config") os.Exit(1) } + if cfg.LLDAPBindPWSource == config.BindPWSourceBoth { + log.Warn().Str("env", config.LLDAPBindPWEnv).Msg("lldap.bindPW in the config is redundant: the same value is supplied by the environment") + } // ----------------------------------------------------------------- // 2. Validate config. diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 6376708..99b41dc 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -31,6 +31,46 @@ type Config struct { Environment string `yaml:"environment"` TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"` TokenStatus TokenStatusConfig `yaml:"tokenStatus,omitempty"` + + // LLDAPBindPWSource records where LLDAP.BindPW came from after Load: one of + // BindPWSourceConfig, BindPWSourceEnv, BindPWSourceBoth or "" (none). It is + // never read from YAML. + LLDAPBindPWSource string `yaml:"-"` +} + +// LLDAPBindPWEnv supplies the directory bind password from the environment, so a +// deployment can deliver it from its own Secret key instead of the config file. +// lldap-export reads the same name. +const LLDAPBindPWEnv = "KEYCAPE_LLDAP_BIND_PW" + +// Sources of the directory bind password, as recorded in LLDAPBindPWSource. +const ( + BindPWSourceConfig = "config" + BindPWSourceEnv = "env" + BindPWSourceBoth = "env-and-config" +) + +// resolveLLDAPBindPW applies KEYCAPE_LLDAP_BIND_PW. The environment is used when +// the config has no bindPW. When both are set they must be equal: that is the +// transition state while a deployment moves the value out of the config file. +// Different values refuse, because silently preferring either one would hide +// which credential the server actually binds with. +func resolveLLDAPBindPW(cfg *Config, getenv func(string) string) error { + env := getenv(LLDAPBindPWEnv) + switch { + case env == "" && cfg.LLDAP.BindPW == "": + cfg.LLDAPBindPWSource = "" + case env == "": + cfg.LLDAPBindPWSource = BindPWSourceConfig + case cfg.LLDAP.BindPW == "": + cfg.LLDAP.BindPW = env + cfg.LLDAPBindPWSource = BindPWSourceEnv + case cfg.LLDAP.BindPW == env: + cfg.LLDAPBindPWSource = BindPWSourceBoth + default: + return fmt.Errorf("config: lldap.bindPW and %s are both set and differ; supply one, or equal values while migrating", LLDAPBindPWEnv) + } + return nil } // TokenStatusConfig opts a singleton issuer into online access-token status. @@ -99,6 +139,9 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("config: parse %q: %w", path, err) } + if err := resolveLLDAPBindPW(&cfg, os.Getenv); err != nil { + return nil, err + } if value := os.Getenv("KEYCAPE_ACCOUNT_PORTAL_URL"); value != "" { cfg.AccountPortalURL = value } diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index d4236a6..0006b73 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -626,3 +626,49 @@ func TestValidate_DuplicateClientID(t *testing.T) { t.Errorf("expected duplicate clientId error, got %v", errs) } } + +// --------------------------------------------------------------------------- +// KEYCAPE_LLDAP_BIND_PW +// --------------------------------------------------------------------------- + +func loadWithBind(t *testing.T, bindPW, env string) (*config.Config, error) { + t.Helper() + yaml := "issuer: https://auth.example.com\nport: 8080\nlldap:\n url: ldap://lldap:3890\n bindDN: uid=svc-keycape,ou=people,dc=example,dc=local\n" + if bindPW != "" { + yaml += " bindPW: " + bindPW + "\n" + } + t.Setenv(config.LLDAPBindPWEnv, env) + return config.Load(writeTempFile(t, yaml)) +} + +func TestLoad_BindPWSources(t *testing.T) { + cases := []struct { + name, file, env, want, source string + }{ + {"none", "", "", "", ""}, + {"config only", "from-file", "", "from-file", config.BindPWSourceConfig}, + {"env only", "", "from-env", "from-env", config.BindPWSourceEnv}, + {"both equal", "same-value", "same-value", "same-value", config.BindPWSourceBoth}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := loadWithBind(t, tc.file, tc.env) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.LLDAP.BindPW != tc.want || cfg.LLDAPBindPWSource != tc.source { + t.Fatalf("got source %q, want %q (password match %v)", cfg.LLDAPBindPWSource, tc.source, cfg.LLDAP.BindPW == tc.want) + } + }) + } +} + +func TestLoad_BindPWConflictRefusesWithoutValues(t *testing.T) { + _, err := loadWithBind(t, "file-secret-value", "env-secret-value") + if err == nil { + t.Fatal("differing bindPW and KEYCAPE_LLDAP_BIND_PW must refuse") + } + if strings.Contains(err.Error(), "file-secret-value") || strings.Contains(err.Error(), "env-secret-value") { + t.Fatal("error must not contain either password") + } +} diff --git a/workplans/KEY-WP-0046-directory-bind-password-env.md b/workplans/KEY-WP-0046-directory-bind-password-env.md new file mode 100644 index 0000000..ee1af19 --- /dev/null +++ b/workplans/KEY-WP-0046-directory-bind-password-env.md @@ -0,0 +1,49 @@ +--- +id: KEY-WP-0046 +type: workplan +title: "Directory bind password from the environment (KEYCAPE_LLDAP_BIND_PW)" +domain: infotech +repo: key-cape +status: active +owner: claude-code +topic_slug: custodian +created: "2026-10-31" +updated: "2026-10-31" +flavor: extension +depends_on: [] +--- + +Accepted from net-kingdom NK-WP-0056-T04 (founder decision 2026-10-31: environment override, implemented by +claude-code in this repository). NetKingdom moves the LLDAP service binds from personal custody into OpenBao, +delivered by ESO. KeyCape reads `lldap.bindPW` only from `config.yaml` inside the `keycape-config` Secret, so +ESO cannot own that one value. With an environment override the bind is delivered from a dedicated Secret +key, as client secrets already are (`secretRef: env:...`). + +## Accept KEYCAPE_LLDAP_BIND_PW in config.Load + +```task +id: KEY-WP-0046-T01 +status: done +priority: high +``` + +`config.Load` applies `KEYCAPE_LLDAP_BIND_PW` (the name `lldap-export` already reads): used when `bindPW` is +empty; accepted with a warning when equal to `bindPW` (migration state); startup refused when they differ, +with an error that names neither value. `Config.LLDAPBindPWSource` records the source; `check-config` reports +`lldap_bind_pw_source` and `identity-facts` reports `password_source`. Tests in +`src/internal/config/config_test.go`; `docs/operations.md` documents the variable. + +**Done when:** the change is merged with tests and `go vet`/`go test ./...` pass. + +## Image through the owner pipeline + +```task +id: KEY-WP-0046-T02 +status: todo +priority: high +``` + +The Forgejo workflow builds the image from `main`. Record the digest here and in net-kingdom NK-WP-0056-T04; +net-kingdom pins it in `sso-mfa/k8s/keycape/deployment.yaml` together with the new environment entry. + +**Done when:** an image built from a commit containing T01 is published, and its digest is recorded.