From 18917aaf7356028ef4ce0aebdc2f994ee420b1f0 Mon Sep 17 00:00:00 2001 From: Bertrand Gouny Date: Sun, 25 Oct 2026 20:23:30 +0100 Subject: [PATCH] fix(ppolicy): hide password history from readonly accounts Load a targeted pwdHistory deny rule only when ppolicy and the data read-only account are enabled. Continue normal ACL evaluation for other identities so administrator access, replication and later custom grants remain available. Add regression scenarios for default permissions, disabled features, replicated history and password reuse, custom grants and bootstrap fragment replacement. Document that existing databases require an explicit ACL update. --- CHANGELOG.md | 3 + README.md | 8 ++ build/README.md | 4 + build/tests/ppolicy_history.go | 88 +++++++++++++++++++ build/tests/test.go | 1 + build/tests/testdata/ppolicy-history.sh | 83 +++++++++++++++++ .../openldap-bootstrap/assets/ldif/README.md | 10 +++ .../64-password-history-acl.ldif.template | 3 + .../assets/scripts/50-ppolicy.sh | 3 + .../assets/scripts/README.md | 2 +- 10 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 build/tests/ppolicy_history.go create mode 100644 build/tests/testdata/ppolicy-history.sh create mode 100644 services/openldap-bootstrap/assets/ldif/config/ppolicy-readonly/64-password-history-acl.ldif.template diff --git a/CHANGELOG.md b/CHANGELOG.md index c9781e9..6ba1ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www ### Fixed +- Hide `pwdHistory` from the data read-only account when ppolicy is enabled + on new bootstraps, preserving normal ACL evaluation, custom overrides and + history replication. - Create bootstrap, schema conversion and TLS watcher temporary files under `/run/container/tmp` instead of relying on a writable `/tmp`. - Render bootstrap LDIF templates in a temporary writable copy, allowing read-only diff --git a/README.md b/README.md index 075630a..b8f2238 100644 --- a/README.md +++ b/README.md @@ -442,6 +442,14 @@ docker run --rm --user $UID -v $(pwd)/schema:/data \ | `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_EXPIRE_WARNING` | Value for `pwdExpireWarning` | `1209600` | | `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_GRACE_AUTH_NLIMIT` | Value for `pwdGraceAuthNLimit` | `3` | +When ppolicy and the data read-only account are enabled, bootstrap explicitly +denies that account access to `pwdHistory` before its general read grant. Other +identities continue through the usual ACLs: administrators and replication retain +access, while ordinary users are denied by the final catch-all unless a custom +ACL grants access. Replace `64-password-history-acl.ldif.template` through the +custom config directory to override the read-only restriction. This ACL is added +on new bootstraps only; existing databases need an explicit ACL update. + **Unique** | Variable | Description | Default | diff --git a/build/README.md b/build/README.md index 6d39838..1db58db 100644 --- a/build/README.md +++ b/build/README.md @@ -33,6 +33,10 @@ After the shared version check, `tests/` runs integration scenarios on each buil - auditlog: automatic module loading, writes, custom paths, rotation and restarts; - last successful binds: disabled by default, precision throttling, failed binds and compatibility with ppolicy; +- password history privacy: hidden from readonly, ordinary users, owner and + anonymous users by default; retained for administrators and replication, with + reuse checks on every node, custom ACL grants and fragment replacements, and + no extra ACL when ppolicy or the read-only account is disabled; - password policy inactivity: disabled by default, active and idle accounts, first logins and missing prerequisites; - progressive password policy lockouts: disabled by default, doubling and cap, diff --git a/build/tests/ppolicy_history.go b/build/tests/ppolicy_history.go new file mode 100644 index 0000000..660dbe7 --- /dev/null +++ b/build/tests/ppolicy_history.go @@ -0,0 +1,88 @@ +package tests + +import ( + "context" + _ "embed" + "fmt" + "strings" + + "dagger.io/dagger" +) + +// PasswordPolicyHistory checks history privacy, replication and custom ACLs. +func PasswordPolicyHistory(ctx context.Context, client *dagger.Client, image *dagger.Container) error { + passwords, err := rootPasswords(ctx, image) + if err != nil { + return err + } + base := merge(passwords, env{ + "OPENLDAP_BOOTSTRAP_DATA_READONLY": "true", + "OPENLDAP_BOOTSTRAP_DATA_READONLY_PASSWORD_HASHED": passwords["OPENLDAP_BOOTSTRAP_DATA_ROOT_PASSWORD_HASHED"], + }) + + for _, feature := range []string{"OPENLDAP_BOOTSTRAP_PPOLICY", "OPENLDAP_BOOTSTRAP_DATA_READONLY"} { + settings := merge(base, env{"OPENLDAP_BOOTSTRAP_PPOLICY": "true"}, env{feature: "false"}) + disabled, err := expect(ctx, withEnv(image, settings), true, + []string{"config file testing succeeded"}, "container", "--skip-process") + if err != nil { + return fmt.Errorf("history ACL without %s: %w", feature, err) + } + config, err := disabled.container.WithExec([]string{"container", "run", "--", "slapcat", "-n", "0"}).Stdout(ctx) + if err != nil { + return err + } + if strings.Contains(config, "attrs=pwdHistory") { + return fmt.Errorf("password history ACL must not be added when %s is disabled", feature) + } + } + + for _, mode := range []string{"standalone", "replicated", "custom", "replacement"} { + if err := func() error { + nodes := []string{"history-ldap1"} + settings := merge(base, env{"OPENLDAP_BOOTSTRAP_PPOLICY": "true"}) + nodeImage := image + if mode == "custom" { + nodeImage = image.WithNewFile("/container/services/openldap-bootstrap/assets/ldif/config/custom/68-history-reader-acl.ldif.template", `olcAccess: to attrs=pwdHistory + by dn.exact="uid=history-observer,${OPENLDAP_BOOTSTRAP_SUFFIX}" read + by * break +`) + } else if mode == "replacement" { + nodeImage = image.WithNewFile("/container/services/openldap-bootstrap/assets/ldif/config/custom/64-password-history-acl.ldif", "") + } + if mode == "replicated" { + nodes = append(nodes, "history-ldap2") + hosts := make([]string, 0, len(nodes)) + for _, node := range nodes { + hosts = append(hosts, "ldap://"+node+":3890") + } + settings = merge(settings, env{ + "OPENLDAP_BOOTSTRAP_REPLICATION": "true", + "OPENLDAP_BOOTSTRAP_REPLICATION_HOSTS": strings.Join(hosts, " "), + "OPENLDAP_BOOTSTRAP_REPLICATION_DATA_READONLY_PASSWORD": password, + }) + } + services := make([]*dagger.Service, 0, len(nodes)) + defer func() { stopNodes(ctx, services) }() + for _, node := range nodes { + urls := env{"OPENLDAP_URLS": "ldap://" + node + ":3890 ldapi:///"} + service, err := startNode(ctx, nodeImage, node, merge(settings, urls), nil, nil) + if err != nil { + return err + } + services = append(services, service) + } + return check(ctx, image, nil, env{ + "TEST_NODES": strings.Join(nodes, " "), + "TEST_HISTORY_REPLICATION": fmt.Sprint(mode == "replicated"), + "TEST_HISTORY_CUSTOM": fmt.Sprint(mode == "custom"), + "TEST_HISTORY_READONLY_ACCESS": fmt.Sprint(mode == "replacement"), + }, ppolicyHistoryCheck) + }(); err != nil { + return fmt.Errorf("password history %s: %w", mode, err) + } + } + return nil +} + +//go:embed testdata/ppolicy-history.sh +var ppolicyHistoryCheck string diff --git a/build/tests/test.go b/build/tests/test.go index dd62749..429bd57 100644 --- a/build/tests/test.go +++ b/build/tests/test.go @@ -26,6 +26,7 @@ var scenarios = []scenario{ {"automatic module loading", AutomaticModules}, {"audit logging", AuditLog}, {"last successful binds", LastBind}, + {"password history privacy", PasswordPolicyHistory}, {"password policy inactivity", PasswordPolicyIdle}, {"progressive password policy lockouts", PasswordPolicyDelay}, {"password helpers", PasswordHelpers}, diff --git a/build/tests/testdata/ppolicy-history.sh b/build/tests/testdata/ppolicy-history.sh new file mode 100644 index 0000000..717aa31 --- /dev/null +++ b/build/tests/testdata/ppolicy-history.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -euo pipefail + +# if container log level is trace: +# print commands and their arguments as they are executed +container log level eq trace && set -x + +# shellcheck disable=SC1091 +source /tmp/openldap-test-lib.sh + +read -ra NODES <<< "${TEST_NODES}" +FIRST="ldap://${NODES[0]}:3890" +USER_DN="uid=history-user,${TEST_SUFFIX}" +OBSERVER_DN="uid=history-observer,${TEST_SUFFIX}" +READONLY_DN="cn=readonly,${TEST_SUFFIX}" +REPLICATOR_DN="cn=replicator,${TEST_SUFFIX}" +NEW_PASSWORD="Changed-passw0rd-2026" + +eventually has_entry "${FIRST}" "${TEST_SUFFIX}" +for user in history-user history-observer; do + expect_rc 0 bind_modify "${FIRST}" "${ADMIN_DN}" -a <