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.
This commit is contained in:
Bertrand Gouny
2026-10-25 20:23:30 +01:00
parent 89dbc0177c
commit 18917aaf73
10 changed files with 204 additions and 1 deletions
+3
View File
@@ -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
+8
View File
@@ -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 |
+4
View File
@@ -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,
+88
View File
@@ -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
+1
View File
@@ -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},
+83
View File
@@ -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 <<LDIF
dn: uid=${user},${TEST_SUFFIX}
objectClass: inetOrgPerson
uid: ${user}
cn: ${user}
sn: User
userPassword: ${TEST_PASSWORD}
LDIF
done
expect_rc 0 ldappasswd -x -H "${FIRST}" -D "${USER_DN}" -w "${TEST_PASSWORD}" -a "${TEST_PASSWORD}" -s "${NEW_PASSWORD}"
expect_rc 0 bind_search "${FIRST}" "${ADMIN_DN}" -b "${USER_DN}" -s base pwdHistory
expect_output '^pwdHistory:'
EXPECTED_HISTORY=$(sed -n '/^pwdHistory:/p' "${OUTPUT_FILE}" | sort)
ok 'a self-service password change creates history readable by the database administrator'
history_matches() {
local actual
actual=$(bind_search "$1" "${ADMIN_DN}" -b "${USER_DN}" -s base pwdHistory | sed -n '/^pwdHistory:/p' | sort)
[ "${actual}" = "${EXPECTED_HISTORY}" ]
}
for node in "${NODES[@]}"; do
uri="ldap://${node}:3890"
eventually history_matches "${uri}"
eventually has_entry "${uri}" "${OBSERVER_DN}"
expect_rc 0 bind_search "${uri}" "${READONLY_DN}" -b "${USER_DN}" -s base cn userPassword pwdHistory
expect_output '^cn: history-user$'
expect_no_output '^userPassword:'
if [ "${TEST_HISTORY_READONLY_ACCESS:-false}" = "true" ]; then
expect_output '^pwdHistory:'
else
expect_no_output '^pwdHistory:'
fi
expect_rc 0 bind_search "${uri}" "${OBSERVER_DN}" -b "${USER_DN}" -s base cn pwdHistory
expect_output '^cn: history-user$'
if [ "${TEST_HISTORY_CUSTOM:-false}" = "true" ]; then
expect_output '^pwdHistory:'
else
expect_no_output '^pwdHistory:'
fi
expect_rc 0 ldapsearch -LLL -x -H "${uri}" -D "${USER_DN}" -w "${NEW_PASSWORD}" -b "${USER_DN}" -s base cn pwdHistory
expect_output '^cn: history-user$'
expect_no_output '^pwdHistory:'
expect_rc 32 ldapsearch -LLL -x -H "${uri}" -b "${USER_DN}" -s base pwdHistory
expect_no_output '^pwdHistory:'
if [ "${TEST_HISTORY_REPLICATION}" = "true" ]; then
expect_rc 0 bind_search "${uri}" "${REPLICATOR_DN}" -b "${USER_DN}" -s base pwdHistory
expect_output '^pwdHistory:'
fi
expect_fail ldappasswd -x -H "${uri}" -D "${USER_DN}" -w "${NEW_PASSWORD}" -a "${NEW_PASSWORD}" -s "${TEST_PASSWORD}"
expect_output 'history'
done
ok 'history access follows the readonly restriction, later custom grants and fragment overrides'
ok 'history remains hidden from its owner and anonymous users by default'
ok 'history is preserved across nodes and prevents password reuse'
@@ -18,6 +18,7 @@ ldif/
│ ├── monitor-readonly/ # Read-only monitor user (OPENLDAP_BOOTSTRAP_MONITOR_READONLY=true)
│ ├── auditlog/ # Audit file (OPENLDAP_BOOTSTRAP_AUDITLOG_ENABLED=true)
│ ├── ppolicy/ # Password policy overlay (OPENLDAP_BOOTSTRAP_PPOLICY=true)
│ ├── ppolicy-readonly/ # Hide history from the data read-only account (PPOLICY and DATA_READONLY enabled)
│ ├── refint/ # Referential integrity overlay (OPENLDAP_BOOTSTRAP_REFINT=true)
│ ├── unique/ # Unique attributes overlay (OPENLDAP_BOOTSTRAP_UNIQUE=true)
│ ├── memberof/ # Memberof overlay (OPENLDAP_BOOTSTRAP_MEMBEROF=true)
@@ -75,6 +76,15 @@ Core OpenLDAP configuration applied on every bootstrap. Files are numbered to co
ACL files ending in `-close.ldif` append a final `access to * by * none` rule to deny any unmatched access, ensuring a safe default.
## config/ppolicy-readonly
`64-password-history-acl.ldif.template` denies `pwdHistory` access to the data
read-only account before its general read grant. It is loaded only when ppolicy
and the data read-only account are both enabled. Its final `by * break` lets other
identities follow the usual database ACLs, including administrator and replication
access and any custom grants before the final deny-all. Replace this fragment
through the custom config directory to change the read-only restriction.
## data/ppolicy
`50-ppolicy-policy.ldif.template` sets the default password policy, including
@@ -0,0 +1,3 @@
olcAccess: to attrs=pwdHistory
by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_READONLY_DN}" none
by * break
@@ -62,6 +62,9 @@ if [ "${OPENLDAP_BOOTSTRAP_PPOLICY}" = "true" ]; then
OUTPUT_DIR="$3"
prepare_ldif_files "${SOURCE_DIR}/ppolicy" "${OUTPUT_DIR}"
if [ "${OPENLDAP_BOOTSTRAP_DATA_READONLY}" = "true" ]; then
prepare_ldif_files "${SOURCE_DIR}/ppolicy-readonly" "${OUTPUT_DIR}"
fi
fi
fi
@@ -19,7 +19,7 @@ Scripts are executed in numeric order. Most are feature-gated by an environment
| `35-auditlog.sh` | `OPENLDAP_BOOTSTRAP_AUDITLOG_ENABLED=true` | Configures file-based auditing on the data database |
| `40-refint.sh` | `OPENLDAP_BOOTSTRAP_REFINT=true` | Configures the referential integrity overlay and optional replacement DN |
| `45-refint-nothing.sh` | `OPENLDAP_BOOTSTRAP_REFINT=true` and `OPENLDAP_BOOTSTRAP_REFINT_NOTHING_DN` non-empty | Creates the passwordless replacement role |
| `50-ppolicy.sh` | `OPENLDAP_BOOTSTRAP_PPOLICY=true` | Configures the password policy and validates inactivity and progressive lockout prerequisites |
| `50-ppolicy.sh` | `OPENLDAP_BOOTSTRAP_PPOLICY=true` | Configures password policy, hides history from the data read-only account while preserving normal ACL evaluation, and validates inactivity and progressive lockout prerequisites |
| `60-unique.sh` | `OPENLDAP_BOOTSTRAP_UNIQUE=true` | Configures the unique attributes overlay |
| `70-memberof.sh` | `OPENLDAP_BOOTSTRAP_MEMBEROF=true` | Configures the memberof overlay |
| `80-replication.sh` | `OPENLDAP_BOOTSTRAP_REPLICATION=true` | Sets up multi-provider syncrepl replication (requires 2 to 9 hosts) |