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

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

View file

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

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

View file

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