KEY-WP-0046: directory bind password from KEYCAPE_LLDAP_BIND_PW
Some checks failed
Authentication acceptance / acceptance (push) Failing after 1m12s
Authentication acceptance / provider-contract (push) Successful in 14s
Build and Publish Container Image / build-and-push (push) Successful in 32s

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 896183@bnt-lap001
Assistant-Session: 3a18c01a-3205-4d02-94db-9efff25649a2
This commit is contained in:
tegwick 2026-10-31 10:24:22 +01:00
parent a1ab8ff807
commit e2b5fa1442
8 changed files with 160 additions and 2 deletions

View file

@ -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
}

View file

@ -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 {

View file

@ -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.

View file

@ -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
}

View file

@ -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")
}
}