mirror of
https://github.com/osixia/container-openldap.git
synced 2026-11-06 12:40:36 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
+3
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user