diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d557d..c9781e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www ### Added +- Add `openldap-ctl validate-ldif` for files and directories to check data entry LDIF + syntax, values and schemas without importing entries or stopping slapd. - Expose bootstrap settings for pending request limits, incoming LDAP message sizes and blocked network write timeouts, preserving OpenLDAP defaults. - Add `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY` and `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY` diff --git a/README.md b/README.md index 6264fa9..075630a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - [🔁 Replication](#-replication) - [🛠️ LDAP Toolkit](#️-ldap-toolkit) - [OpenLDAP Utilities](#openldap-utilities) + - [Validate LDIF](#validate-ldif) - [Password Helpers](#password-helpers) - [Convert Schema to LDIF](#convert-schema-to-ldif) - [🔤 Environment Variables](#-environment-variables) @@ -293,6 +294,21 @@ docker run --rm osixia/openldap:2.6.15-beta run -- \ -s newpassword "uid=jdoe,ou=people,dc=example,dc=org" ``` +### Validate LDIF + +Check data entries against the initialized local configuration without importing them or stopping slapd: + +```bash +docker exec openldap container run -- openldap-ctl validate-ldif /data/users.ldif +docker exec openldap container run -- openldap-ctl validate-ldif /data/users.ldif /data/groups.ldif +docker exec openldap container run -- openldap-ctl validate-ldif /data/ldif/ +``` + +Directories are searched recursively for `*.ldif`, sorted by version and excluding hidden paths. +Each file must contain complete entries. All files are checked; any validation failure returns a non-zero status. +Checks LDIF syntax, attribute values and schemas. Does not check existing entries, +ACLs or overlay rules, and does not accept `ldapmodify` change records. + ### Password Helpers Generate a random password: diff --git a/build/README.md b/build/README.md index 33fb8d9..6d39838 100644 --- a/build/README.md +++ b/build/README.md @@ -44,6 +44,9 @@ After the shared version check, `tests/` runs integration scenarios on each buil - `openldap-ctl` backup and restore; - password hashing: arguments, secret files and stdin, exact contents, custom hash commands and trace output without file/stdin secrets; +- LDIF validation: syntax, required attributes, value syntax and suffix checks, + multiple files, recursive directories and projected-volume symlinks, with unchanged + configuration and data while slapd is stopped or running; - upgrade: pre-upgrade backup on a simulated N-1 image, restore on N, downgrade refused; - upgrade from previous published releases (`previousReleases` in `tests/upgrade.go`). diff --git a/build/tests/test.go b/build/tests/test.go index a96de9f..dd62749 100644 --- a/build/tests/test.go +++ b/build/tests/test.go @@ -29,6 +29,7 @@ var scenarios = []scenario{ {"password policy inactivity", PasswordPolicyIdle}, {"progressive password policy lockouts", PasswordPolicyDelay}, {"password helpers", PasswordHelpers}, + {"LDIF validation", ValidateLDIF}, {"refint empty-group placeholders", RefintNothing}, {"all features with 3 replicated nodes", AllFeatures}, {"backup and restore", BackupRestore}, diff --git a/build/tests/testdata/validate-ldif.sh b/build/tests/testdata/validate-ldif.sh new file mode 100644 index 0000000..2d20abc --- /dev/null +++ b/build/tests/testdata/validate-ldif.sh @@ -0,0 +1,128 @@ +#!/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 + +if [ "${TEST_VALIDATE_LDIF_MODE}" = online ] && [ "${1:-}" != --online ]; then + printf 'bash %q --online\n' "$0" | container --bash + exit 0 +fi + +if [ "${TEST_VALIDATE_LDIF_MODE}" = offline ]; then + expect_rc 0 container --skip-process +else + eventually has_entry ldap://127.0.0.1:3890 "${TEST_SUFFIX}" + PID_BEFORE=$(cat /run/container/processes/openldap.pid) +fi + +TEST_DIR=$(mktemp -d) +trap 'rm -rf "${TEST_DIR:?}"' EXIT + +snapshot() { + find /etc/openldap/slapd.d -type f -exec sha256sum {} \; | sort + sha256sum /var/lib/openldap/openldap-data/data.mdb +} +snapshot > "${TEST_DIR}/before" + +USER_DN="uid=validate-only,${TEST_SUFFIX}" +cat > "${TEST_DIR}/valid entries.ldif" < "${TEST_DIR}/second.ldif" +expect_rc 0 container run -- openldap-ctl validate-ldif "${TEST_DIR}/projected.ldif" "${TEST_DIR}/second.ldif" +expect_output 'LDIF validation succeeded: .*projected.ldif$' +expect_output 'LDIF validation succeeded: .*second.ldif$' + +mkdir -p "${TEST_DIR}/directory/..revision" "${TEST_DIR}/directory/nested" +cat "${TEST_DIR}/second.ldif" > "${TEST_DIR}/directory/..revision/2-second.ldif" +cat "${TEST_DIR}/valid entries.ldif" > "${TEST_DIR}/directory/..revision/10-last.ldif" +cat "${TEST_DIR}/valid entries.ldif" > "${TEST_DIR}/directory/nested/entry with spaces.ldif" +ln -s ..revision "${TEST_DIR}/directory/..data" +ln -s ..data/2-second.ldif "${TEST_DIR}/directory/2-second.ldif" +ln -s ..data/10-last.ldif "${TEST_DIR}/directory/10-last.ldif" +printf 'invalid LDIF\n' > "${TEST_DIR}/directory/.hidden.ldif" +printf 'invalid LDIF\n' > "${TEST_DIR}/directory/ignored.ldif.template" +expect_rc 0 container run -- openldap-ctl validate-ldif "${TEST_DIR}/directory" +mapfile -t VALIDATED_FILES < <(sed -n 's/.*LDIF validation succeeded: //p' "${OUTPUT_FILE}") +[ "${#VALIDATED_FILES[@]}" -eq 3 ] || fail 'directory entries were skipped or validated twice' +[ "${VALIDATED_FILES[0]}" = "${TEST_DIR}/directory/2-second.ldif" ] || fail 'directory ordering is not numeric' +[ "${VALIDATED_FILES[1]}" = "${TEST_DIR}/directory/10-last.ldif" ] || fail 'directory ordering is not numeric' +[ "${VALIDATED_FILES[2]}" = "${TEST_DIR}/directory/nested/entry with spaces.ldif" ] || fail 'nested LDIF was not validated' +ok 'multiple files, recursive directories and projected-volume symlinks are supported' + +sed '/^sn:/d' "${TEST_DIR}/valid entries.ldif" > "${TEST_DIR}/missing-sn.ldif" +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/missing-sn.ldif" +expect_output 'requires attribute.*sn' + +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/projected.ldif" "${TEST_DIR}/missing-sn.ldif" "${TEST_DIR}/second.ldif" +expect_output 'LDIF validation succeeded: .*second.ldif$' +expect_output 'One or more LDIF files failed validation' +cat "${TEST_DIR}/missing-sn.ldif" > "${TEST_DIR}/directory/nested/invalid.ldif" +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/directory" +expect_output 'requires attribute.*sn' + +cat "${TEST_DIR}/valid entries.ldif" > "${TEST_DIR}/unknown-attribute.ldif" +printf 'unknownAttribute: value\n' >> "${TEST_DIR}/unknown-attribute.ldif" +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/unknown-attribute.ldif" +expect_output 'attribute type undefined' + +cat > "${TEST_DIR}/invalid-value.ldif" < "${TEST_DIR}/wrong-suffix.ldif" +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/wrong-suffix.ldif" +expect_output 'not configured to hold' + +printf 'dn: %s\nchangetype: modify\nreplace: cn\ncn: Changed\n' "${USER_DN}" > "${TEST_DIR}/modify.ldif" +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/modify.ldif" +printf 'dn: invalid-dn\nobjectClass: inetOrgPerson\n' > "${TEST_DIR}/invalid-dn.ldif" +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/invalid-dn.ldif" +ok "LDIF validation (${TEST_VALIDATE_LDIF_MODE}) rejects invalid entries and change records" + +expect_fail container run -- openldap-ctl validate-ldif +expect_output 'Usage: openldap-ctl validate-ldif' +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/projected.ldif" extra +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/missing.ldif" +mkdir "${TEST_DIR}/empty" +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/empty" +expect_output 'No LDIF files found' +mkdir "${TEST_DIR}/loop" +ln -s . "${TEST_DIR}/loop/cycle" +expect_fail container run -- openldap-ctl validate-ldif "${TEST_DIR}/loop" +expect_fail env OPENLDAP_CONF_DIR="${TEST_DIR}" container run -- openldap-ctl validate-ldif "${TEST_DIR}/projected.ldif" +expect_output 'configuration is not initialized' +expect_rc 0 container run -- openldap-ctl validate-ldif --help + +snapshot > "${TEST_DIR}/after" +cmp "${TEST_DIR}/before" "${TEST_DIR}/after" || fail 'validation changed config or data files' +[ -z "$(find /run/container/tmp -maxdepth 1 -name 'openldap-validate.*' -print)" ] || fail 'validation left a temporary file' + +if [ "${TEST_VALIDATE_LDIF_MODE}" = online ]; then + [ "$(cat /run/container/processes/openldap.pid)" = "${PID_BEFORE}" ] || fail 'validation restarted slapd' + expect_rc 32 bind_search ldap://127.0.0.1:3890 "${ADMIN_DN}" -b "${USER_DN}" -s base dn + expect_rc 0 bind_search ldap://127.0.0.1:3890 "${ADMIN_DN}" -b "${TEST_SUFFIX}" -s base dn +else + [ ! -e /run/container/processes/openldap.pid ] || fail 'validation started slapd' +fi +ok "LDIF validation (${TEST_VALIDATE_LDIF_MODE}) leaves configuration, data and process state unchanged" diff --git a/build/tests/validate.go b/build/tests/validate.go new file mode 100644 index 0000000..4d22476 --- /dev/null +++ b/build/tests/validate.go @@ -0,0 +1,27 @@ +package tests + +import ( + "context" + _ "embed" + "fmt" + + "dagger.io/dagger" +) + +// ValidateLDIF checks entry validation without changing local config or data. +func ValidateLDIF(ctx context.Context, client *dagger.Client, image *dagger.Container) error { + passwords, err := rootPasswords(ctx, image) + if err != nil { + return err + } + for _, mode := range []string{"offline", "online"} { + if err := check(ctx, withEnv(image, passwords), nil, + env{"TEST_VALIDATE_LDIF_MODE": mode}, validateLDIFCheck); err != nil { + return fmt.Errorf("LDIF validation %s: %w", mode, err) + } + } + return nil +} + +//go:embed testdata/validate-ldif.sh +var validateLDIFCheck string diff --git a/services/openldap/assets/openldap-ctl/command/validate-ldif.sh b/services/openldap/assets/openldap-ctl/command/validate-ldif.sh new file mode 100755 index 0000000..27cedc6 --- /dev/null +++ b/services/openldap/assets/openldap-ctl/command/validate-ldif.sh @@ -0,0 +1,67 @@ +#!/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 + +usage() { + echo "Usage: openldap-ctl validate-ldif file-or-directory [file-or-directory ...]" +} + +if [ "$#" -eq 1 ] && { [ "$1" = --help ] || [ "$1" = -h ]; }; then + usage + exit 0 +fi + +if [ "$#" -eq 0 ]; then + usage + container log fatal "At least one LDIF file or directory is required" +fi + +if [ ! -f "${OPENLDAP_CONF_DIR}/cn=config.ldif" ]; then + container log fatal "OpenLDAP configuration is not initialized in ${OPENLDAP_CONF_DIR}" +fi + +LDIF_FILES=() +LDIF_LIST_FILE=$(mktemp /run/container/tmp/openldap-validate.XXXXXX) +trap 'rm -f "${LDIF_LIST_FILE}"' EXIT + +for path in "$@"; do + if [ -d "${path}" ]; then + # Prefix relative paths so find never interprets them as expressions. + if [[ "${path}" != /* ]]; then + path="./${path}" + fi + # Follow projected-volume symlinks, but skip hidden revision directories + # to avoid validating their entries twice. Keep traversal errors fatal. + find -L "${path}" -mindepth 1 -name '.*' -prune -o \ + -type f -name '*.ldif' -print0 | sort -zV > "${LDIF_LIST_FILE}" + if [ ! -s "${LDIF_LIST_FILE}" ]; then + container log fatal "No LDIF files found in directory: ${path}" + fi + while IFS= read -r -d '' file; do + LDIF_FILES+=("${file}") + done < "${LDIF_LIST_FILE}" + elif [ -f "${path}" ] && [ -r "${path}" ]; then + LDIF_FILES+=("${path}") + else + container log fatal "Path must be a readable LDIF file or directory: ${path}" + fi +done + +FAILED=false +for file in "${LDIF_FILES[@]}"; do + # Dry-run mode skips database writes; value-check also validates attribute syntax. + if slapadd -u -o value-check=yes -F "${OPENLDAP_CONF_DIR}" -n 1 -l "${file}"; then + container log info "LDIF validation succeeded: ${file}" + else + container log error "LDIF validation failed: ${file}" + FAILED=true + fi +done + +if [ "${FAILED}" = true ]; then + container log fatal "One or more LDIF files failed validation" +fi