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 <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:
parent
a1ab8ff807
commit
e2b5fa1442
8 changed files with 160 additions and 2 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
workplans/KEY-WP-0046-directory-bind-password-env.md
Normal file
49
workplans/KEY-WP-0046-directory-bind-password-env.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue