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 <