From 276167073972bff459cf0fdfe7e8f15045cfe0ca Mon Sep 17 00:00:00 2001 From: Bertrand Gouny Date: Tue, 27 Oct 2026 13:05:36 +0100 Subject: [PATCH] feat(bootstrap): add scoped directory administrator Create an optional administrator for user/group CRUD and password resets while protecting service accounts, password hashes/history and cn=config. Document the distinct data root, config root, admin and management roles. Keep conditional history ACLs in their account directories as optional templates, activated by feature scripts after rendering in a temporary copy. Cover read-only sources, admin permissions, readonly history privacy and custom overrides. --- CHANGELOG.md | 10 + README.md | 7 +- build/README.md | 4 + build/tests/data_admin.go | 109 +++++++++++ build/tests/ppolicy_history.go | 4 +- build/tests/test.go | 1 + build/tests/testdata/data-admin.sh | 171 ++++++++++++++++++ build/tests/testdata/templates.sh | 7 + environment/.env.bootstrap | 4 + .../openldap-bootstrap/assets/ldif/README.md | 80 +++++++- ...e-admin-ppolicy-acl.ldif.optional.template | 3 + .../62-database-admin-acl.ldif.template | 28 +++ ...adonly-ppolicy-acl.ldif.optional.template} | 0 .../70-database-admin-account.ldif.template | 6 + .../assets/scripts/20-database-readonly.sh | 3 + .../assets/scripts/23-database-admin.sh | 41 +++++ .../assets/scripts/50-ppolicy.sh | 3 - .../assets/scripts/README.md | 9 +- .../assets/scripts/helpers/common.sh | 2 +- 19 files changed, 474 insertions(+), 18 deletions(-) create mode 100644 build/tests/data_admin.go create mode 100644 build/tests/testdata/data-admin.sh create mode 100644 services/openldap-bootstrap/assets/ldif/config/database-admin/61-database-admin-ppolicy-acl.ldif.optional.template create mode 100644 services/openldap-bootstrap/assets/ldif/config/database-admin/62-database-admin-acl.ldif.template rename services/openldap-bootstrap/assets/ldif/config/{ppolicy-readonly/64-password-history-acl.ldif.template => database-readonly/64-database-readonly-ppolicy-acl.ldif.optional.template} (100%) create mode 100644 services/openldap-bootstrap/assets/ldif/data/database-admin/70-database-admin-account.ldif.template create mode 100755 services/openldap-bootstrap/assets/scripts/23-database-admin.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index c67b06c..41f39eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www ### Added +- Add optional `OPENLDAP_BOOTSTRAP_DATA_ADMIN` for a real directory administrator + entry that can manage users/groups and reset their passwords, while protecting + service accounts, its own account, password hashes/history and `cn=config`. + Document how this scoped administrator differs from the data/config root DNs + and the application management account. - Expose management account hidden attributes through `OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT_HIDDEN_ATTRIBUTES` and `OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT_HIDDEN_PPOLICY_ATTRIBUTES`. @@ -40,6 +45,11 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www ### Changed +- Keep password-history ACL fragments in their account feature directories as + `.ldif.optional.template` files, activated with `mv` by the account scripts. + The helper only renders/copies fragments; final assembly ignores inactive files. + Read-only history overrides must use `64-database-readonly-ppolicy-acl.ldif`. + - Rename bootstrap feature flags `OPENLDAP_BOOTSTRAP_MONITOR_ENABLED` and `OPENLDAP_BOOTSTRAP_AUDITLOG_ENABLED` to `OPENLDAP_BOOTSTRAP_MONITOR` and `OPENLDAP_BOOTSTRAP_AUDITLOG`. Update deployment environments to the new names; diff --git a/README.md b/README.md index 935e51d..6116244 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,9 @@ docker run --rm --user $UID -v $(pwd)/schema:/data \ | `OPENLDAP_BOOTSTRAP_DATA_TREE_USERS_DN` | Users OU DN | `ou=users,${OPENLDAP_BOOTSTRAP_SUFFIX}` | | `OPENLDAP_BOOTSTRAP_DATA_TREE_GROUPS_DN` | Groups OU DN | `ou=groups,${OPENLDAP_BOOTSTRAP_SUFFIX}` | | `OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN` | Services OU DN | `ou=services,${OPENLDAP_BOOTSTRAP_SUFFIX}` | +| `OPENLDAP_BOOTSTRAP_DATA_ADMIN` | Create an account to administer users and groups | `false` | +| `OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN` | Scoped administrator DN | `cn=admin,${OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN}` | +| `OPENLDAP_BOOTSTRAP_DATA_ADMIN_PASSWORD_HASHED` | Hashed administrator password; generated if empty when enabled | `` | | `OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT` | Enable user/group reads and user password resets | `false` | | `OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT_DN` | Management account DN | `cn=management,${OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN}` | | `OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT_PASSWORD_HASHED` | Hashed management password; generated if empty when enabled | `` | @@ -455,8 +458,8 @@ 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 +ACL grants access. Override `64-database-readonly-ppolicy-acl.ldif` through the +custom config directory to change the read-only restriction. This ACL is added on new bootstraps only; existing databases need an explicit ACL update. **Unique** diff --git a/build/README.md b/build/README.md index 066ea84..0384779 100644 --- a/build/README.md +++ b/build/README.md @@ -21,6 +21,10 @@ own native runner, then publishes them together from their OCI archives After the shared version check, `tests/` runs integration scenarios on each built image: - default bootstrap; +- scoped directory administrator: disabled by default, custom DNs and supplied + OUs, generated passwords, readable account identifiers for directory clients, + user/group creation, edits, renames, deletion and password resets, hidden + hashes/history, and protected service accounts, own account and configuration; - standard users/groups/services tree and management account: independent opt-ins, custom DNs and supplied OUs, generated passwords, user/group reads, password resets including application administrators, ppolicy-safe resets, diff --git a/build/tests/data_admin.go b/build/tests/data_admin.go new file mode 100644 index 0000000..c8535be --- /dev/null +++ b/build/tests/data_admin.go @@ -0,0 +1,109 @@ +package tests + +import ( + "context" + _ "embed" + "fmt" + "strings" + + "dagger.io/dagger" +) + +// DataAdmin checks the scoped directory administrator and coexistence with other accounts. +func DataAdmin(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_TREE": "true", + "OPENLDAP_BOOTSTRAP_DATA_ADMIN": "true", + "OPENLDAP_BOOTSTRAP_DATA_ADMIN_PASSWORD_HASHED": passwords["OPENLDAP_BOOTSTRAP_DATA_ROOT_PASSWORD_HASHED"], + "OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT": "true", + "OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT_PASSWORD_HASHED": passwords["OPENLDAP_BOOTSTRAP_DATA_ROOT_PASSWORD_HASHED"], + }) + for _, mode := range []string{"disabled", "plain", "policy", "custom", "supplied"} { + if err := func() error { + usersDN := "ou=users," + suffix + groupsDN := "ou=groups," + suffix + servicesDN := "ou=services," + suffix + settings := merge(base) + nodeImage := image + switch mode { + case "disabled": + settings["OPENLDAP_BOOTSTRAP_DATA_ADMIN"] = "false" + case "policy": + settings = merge(settings, env{ + "OPENLDAP_BOOTSTRAP_PPOLICY": "true", + "OPENLDAP_BOOTSTRAP_DATA_READONLY": "true", + "OPENLDAP_BOOTSTRAP_DATA_READONLY_PASSWORD_HASHED": passwords["OPENLDAP_BOOTSTRAP_DATA_ROOT_PASSWORD_HASHED"], + }) + case "custom": + usersDN = "ou=people," + suffix + groupsDN = "ou=roles," + suffix + servicesDN = "ou=applications," + suffix + case "supplied": + settings["OPENLDAP_BOOTSTRAP_DATA_TREE"] = "false" + nodeImage = image.WithNewFile("/container/services/openldap-bootstrap/assets/ldif/data/custom/30-custom-tree.ldif", fmt.Sprintf(`dn: %s +objectClass: organizationalUnit +ou: users + +dn: %s +objectClass: organizationalUnit +ou: groups + +dn: %s +objectClass: organizationalUnit +ou: services +`, usersDN, groupsDN, servicesDN)) + } + adminDN := "cn=admin," + servicesDN + if mode == "custom" { + adminDN = "cn=directory-admin," + servicesDN + } + managementDN := "cn=management," + servicesDN + settings = merge(settings, env{ + "OPENLDAP_BOOTSTRAP_DATA_TREE_USERS_DN": usersDN, + "OPENLDAP_BOOTSTRAP_DATA_TREE_GROUPS_DN": groupsDN, + "OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN": servicesDN, + "OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN": adminDN, + "OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT_DN": managementDN, + }) + node := "directory-admin-" + mode + service, err := startNode(ctx, nodeImage, node, settings, nil, nil) + if err != nil { + return err + } + defer stopNodes(ctx, []*dagger.Service{service}) + return check(ctx, image, nil, env{ + "TEST_NODES": node, + "TEST_ADMIN_MODE": mode, + "TEST_USERS_DN": usersDN, + "TEST_GROUPS_DN": groupsDN, + "TEST_SERVICES_DN": servicesDN, + "TEST_DIRECTORY_ADMIN_DN": adminDN, + "TEST_MANAGEMENT_DN": managementDN, + "TEST_PASSWORD_HASHED": passwords["OPENLDAP_BOOTSTRAP_DATA_ROOT_PASSWORD_HASHED"], + }, dataAdminCheck) + }(); err != nil { + return fmt.Errorf("directory admin %s: %w", mode, err) + } + } + + generated, err := expect(ctx, withEnv(image, merge(base, env{"OPENLDAP_BOOTSTRAP_DATA_ADMIN_PASSWORD_HASHED": ""})), true, + []string{"Generated database admin user password:", "config file testing succeeded"}, "container", "--skip-process") + if err != nil { + return err + } + data, err := generated.container.WithExec([]string{"container", "run", "--", "slapcat", "-n", "1", "-a", "(cn=admin)"}).Stdout(ctx) + if err != nil { + return err + } + if !strings.Contains(data, "dn: cn=admin,ou=services,"+suffix) || !strings.Contains(data, "userPassword:: ") { + return fmt.Errorf("generated directory admin account/password missing") + } + return nil +} + +//go:embed testdata/data-admin.sh +var dataAdminCheck string diff --git a/build/tests/ppolicy_history.go b/build/tests/ppolicy_history.go index 660dbe7..37b728a 100644 --- a/build/tests/ppolicy_history.go +++ b/build/tests/ppolicy_history.go @@ -47,7 +47,7 @@ func PasswordPolicyHistory(ctx context.Context, client *dagger.Client, image *da by * break `) } else if mode == "replacement" { - nodeImage = image.WithNewFile("/container/services/openldap-bootstrap/assets/ldif/config/custom/64-password-history-acl.ldif", "") + nodeImage = image.WithNewFile("/container/services/openldap-bootstrap/assets/ldif/config/custom/64-database-readonly-ppolicy-acl.ldif", "") } if mode == "replicated" { nodes = append(nodes, "history-ldap2") @@ -64,7 +64,7 @@ func PasswordPolicyHistory(ctx context.Context, client *dagger.Client, image *da services := make([]*dagger.Service, 0, len(nodes)) defer func() { stopNodes(ctx, services) }() for _, node := range nodes { - urls := env{"OPENLDAP_URLS": "ldap://" + node + ":3890 ldapi:///"} + urls := env{"OPENLDAP_URLS": "ldap://" + node + ":3890 ldaps://" + node + ":6360 ldapi:///"} service, err := startNode(ctx, nodeImage, node, merge(settings, urls), nil, nil) if err != nil { return err diff --git a/build/tests/test.go b/build/tests/test.go index cc6bc92..b373f7d 100644 --- a/build/tests/test.go +++ b/build/tests/test.go @@ -20,6 +20,7 @@ type scenario struct { var scenarios = []scenario{ {"default bootstrap", Default}, {"standard data tree and management account", DataManagement}, + {"scoped directory administrator", DataAdmin}, {"global request limits", GlobalLimits}, {"read-only bootstrap templates", ReadOnlyTemplates}, {"alternative schemas", AlternativeSchemas}, diff --git a/build/tests/testdata/data-admin.sh b/build/tests/testdata/data-admin.sh new file mode 100644 index 0000000..3633653 --- /dev/null +++ b/build/tests/testdata/data-admin.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# shellcheck disable=SC2153 + +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 + +URI="ldap://${TEST_NODES}:3890" +DIRECTORY_ADMIN_DN="${TEST_DIRECTORY_ADMIN_DN}" +USER_DN="uid=alice,${TEST_USERS_DN}" +RENAMED_DN="uid=alice-renamed,${TEST_USERS_DN}" +GROUP_DN="cn=team,${TEST_GROUPS_DN}" +RENAMED_GROUP_DN="cn=renamed-team,${TEST_GROUPS_DN}" +SERVICE_DN="cn=other-service,${TEST_SERVICES_DN}" +OUTSIDE_DN="cn=outside,${TEST_SUFFIX}" +NEW_PASSWORD="Changed-passw0rd-2026" + +eventually has_entry "${URI}" "${TEST_SUFFIX}" + +if [ "${TEST_ADMIN_MODE}" = "disabled" ]; then + expect_rc 32 bind_search "${URI}" "${ADMIN_DN}" -b "${DIRECTORY_ADMIN_DN}" -s base dn + expect_rc 0 bind_search "${URI}" "${CONFIG_ADMIN_DN}" -b 'olcDatabase={1}mdb,cn=config' -s base olcAccess + expect_no_output "${DIRECTORY_ADMIN_DN}" + ok 'directory administrator and its ACLs are disabled by default' + exit 0 +fi + +expect_rc 0 ldapwhoami -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" +expect_rc 0 bind_search "${URI}" "${DIRECTORY_ADMIN_DN}" -b "${DIRECTORY_ADMIN_DN}" -s base objectClass cn entryUUID userPassword +expect_output '^objectClass: organizationalRole$' +expect_output '^objectClass: simpleSecurityObject$' +expect_output '^entryUUID:' +expect_no_output '^userPassword:' +ok 'directory administrator can bind and read its real entry and identifier' + +expect_rc 0 bind_modify "${URI}" "${DIRECTORY_ADMIN_DN}" -a < "${test_dir}/source/..revision/10-entry.ldif.template" printf 'description: original\n' > "${test_dir}/source/..revision/20-plain.ldif" +printf 'description: %s\n' "\${OPENLDAP_TEST_DN}" > "${test_dir}/source/..revision/15-optional.ldif.optional.template" ln -s ..revision "${test_dir}/source/..data" ln -s ..data/10-entry.ldif.template "${test_dir}/source/10-entry.ldif.template" ln -s ..data/20-plain.ldif "${test_dir}/source/20-plain.ldif" +ln -s ..data/15-optional.ldif.optional.template "${test_dir}/source/15-optional.ldif.optional.template" chmod -R a-w "${test_dir}/source" export OPENLDAP_TEST_DN='dc=example,dc=org' prepare_ldif_files "${test_dir}/source" "${test_dir}/output" test "$(cat "${test_dir}/output/10-entry.ldif")" = "dn: ${OPENLDAP_TEST_DN}" test "$(cat "${test_dir}/output/20-plain.ldif")" = 'description: original' +test "$(cat "${test_dir}/output/15-optional.ldif.optional")" = "description: ${OPENLDAP_TEST_DN}" +test ! -e "${test_dir}/output/15-optional.ldif" +test -L "${test_dir}/source/15-optional.ldif.optional.template" +test "$(cat "${test_dir}/source/15-optional.ldif.optional.template")" = "description: \${OPENLDAP_TEST_DN}" +test "$(find "${test_dir}/output" -type f -name '*.ldif' | wc -l)" -eq 2 test ! -e "${test_dir}/source/10-entry.ldif" test "$(cat "${test_dir}/source/10-entry.ldif.template")" = "dn: \${OPENLDAP_TEST_DN}" test -L "${test_dir}/source/10-entry.ldif.template" diff --git a/environment/.env.bootstrap b/environment/.env.bootstrap index c8ac078..20ef018 100644 --- a/environment/.env.bootstrap +++ b/environment/.env.bootstrap @@ -31,6 +31,10 @@ OPENLDAP_BOOTSTRAP_DATA_TREE_USERS_DN="ou=users,${OPENLDAP_BOOTSTRAP_SUFFIX}" OPENLDAP_BOOTSTRAP_DATA_TREE_GROUPS_DN="ou=groups,${OPENLDAP_BOOTSTRAP_SUFFIX}" OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN="ou=services,${OPENLDAP_BOOTSTRAP_SUFFIX}" +OPENLDAP_BOOTSTRAP_DATA_ADMIN=false +OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN="cn=admin,${OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN}" +OPENLDAP_BOOTSTRAP_DATA_ADMIN_PASSWORD_HASHED="" + OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT=false OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT_DN="cn=management,${OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN}" OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT_PASSWORD_HASHED="" diff --git a/services/openldap-bootstrap/assets/ldif/README.md b/services/openldap-bootstrap/assets/ldif/README.md index b21fe9c..c284db9 100644 --- a/services/openldap-bootstrap/assets/ldif/README.md +++ b/services/openldap-bootstrap/assets/ldif/README.md @@ -6,6 +6,11 @@ Files with a `.template` extension use `${VARIABLE}` placeholders that are subst Rendered LDIF files are assembled in version-sort order (`sort -V`); numerical prefixes control ordering. +Conditional templates use `.ldif.optional.template`. They render to `.ldif.optional` +in the temporary output directory. The owning feature script activates them with +`mv` to `.ldif` when needed; final assembly ignores files still ending in `.optional`. +Sources stay unchanged, including on read-only volumes. + ## Structure ``` @@ -15,11 +20,11 @@ ldif/ │ ├── tls/ # TLS settings (OPENLDAP_BOOTSTRAP_TLS=true) │ ├── tls-required/ # Enforce TLS (OPENLDAP_BOOTSTRAP_TLS_REQUIRED=true) │ ├── database-readonly/ # Read-only data DB user (OPENLDAP_BOOTSTRAP_DATA_READONLY=true) +│ ├── database-admin/ # Scoped users/groups administrator (OPENLDAP_BOOTSTRAP_DATA_ADMIN=true) │ ├── database-management/ # Scoped application account ACLs (OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT=true) │ ├── monitor-readonly/ # Read-only monitor user (OPENLDAP_BOOTSTRAP_MONITOR_READONLY=true) │ ├── auditlog/ # Audit file (OPENLDAP_BOOTSTRAP_AUDITLOG=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) @@ -28,6 +33,7 @@ ldif/ └── data/ # LDAP directory data (populated on first bootstrap) ├── base/ # Root organization entry (always applied) ├── data-tree/ # Users, groups and services OUs (OPENLDAP_BOOTSTRAP_DATA_TREE=true) + ├── database-admin/ # Directory administrator (OPENLDAP_BOOTSTRAP_DATA_ADMIN=true) ├── database-management/ # Application account (OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT=true) ├── database-readonly/ # Read-only account entry (OPENLDAP_BOOTSTRAP_DATA_READONLY=true) ├── monitor-readonly/ # Read-only monitor account (OPENLDAP_BOOTSTRAP_MONITOR_READONLY=true) @@ -79,6 +85,66 @@ 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. +## Root, admin and management accounts + +A DN identifies an LDAP account or entry. These accounts serve different purposes: + +| Account | Default DN | Purpose and permissions | +|---|---|---| +| Data root | `cn=admin,` | Maintenance superuser for the data database; bypasses that database's ACLs and administrative limits | +| Config root | `cn=admin,cn=config` | Server configuration superuser; can change schemas, ACLs, root passwords and other settings in `cn=config` | +| Directory admin | `cn=admin,ou=services,` | Day-to-day user/group administration, for example through phpLDAPadmin; obeys the scoped ACLs | +| Management | `cn=management,ou=services,` | Application account, for example Authelia; reads users/groups and resets user passwords | + +The two root DNs are configured through `OPENLDAP_BOOTSTRAP_DATA_ROOT_*` and +`OPENLDAP_BOOTSTRAP_CONFIG_ROOT_*`. Each bypasses ACLs only in its own database: +the data root is not automatically the configuration root. Config root can change +the data database's ACLs and root password, so reserve it for server maintenance. +Here, "root" means an LDAP superuser, not the Linux user running the container. + +Root DNs can authenticate without a directory entry. The optional directory admin +has a real entry so clients such as phpLDAPadmin 2 can retrieve it after login. +Its DN and password are separate from the root credentials. Use it for routine +administration and keep root credentials for maintenance/recovery. + +## data/database-admin and config/database-admin + +Enable the standard branches and scoped administrator on a new installation: + +```bash +OPENLDAP_BOOTSTRAP_DATA_TREE=true +OPENLDAP_BOOTSTRAP_DATA_ADMIN=true +OPENLDAP_BOOTSTRAP_DATA_ADMIN_PASSWORD_HASHED="{ARGON2}..." +``` + +An empty hash generates a password and prints it in the bootstrap logs, as for +the other accounts. `OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN` defaults to +`cn=admin,${OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN}`; use a DN starting with `cn=`. +Keep administrator and application accounts outside the writable users/groups +branches, normally in the separate services branch. +The parent OUs must exist: enable tree creation or supply custom LDIFs before +`70-database-admin-account.ldif`. Enabling this option does not modify an existing +database. + +`62-database-admin-acl.ldif.template` lets the administrator create, read, edit, +rename and delete entries in the configured users/groups branches, and reset user +passwords. It denies password hash read/compare and access to `shadowLastChange`; +with ppolicy, `61-database-admin-ppolicy-acl.ldif.optional.template` also denies access to +`pwdHistory`. The administrator can read its own non-secret attributes, including +its identifier, but cannot modify, rename, delete or reset its own account. +Other service accounts, entries outside users/groups and `cn=config` stay protected. +Use data root to rotate the directory administrator's password. + +Password resets use the same `=wm` privileges as management resets. With ppolicy, +these administrative resets bypass quality, history-reuse and minimum-age checks; +the application performing them must enforce the required password rules. +This remains a powerful identity-management account: it can reset users' passwords +and change group memberships, but it cannot grant itself configuration access. + +For phpLDAPadmin 2, configure DN login (`LDAP_LOGIN_ATTR=dn`) and accept this +entry's object class (`LDAP_LOGIN_OBJECTCLASS=organizationalRole`). Configure its +LDAP connection/search credentials and TLS separately. + ## data/data-tree and data/database-management `OPENLDAP_BOOTSTRAP_DATA_TREE=true` creates `ou=users`, `ou=groups` and `ou=services` @@ -106,14 +172,16 @@ minimum-age checks: enforce password requirements in the application. Service accounts and configuration remain protected; other identities keep their usual ACLs. -## config/ppolicy-readonly +## config/database-readonly -`64-password-history-acl.ldif.template` denies `pwdHistory` access to the data +`64-database-readonly-ppolicy-acl.ldif.optional.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 +and the data read-only account are both enabled, by `20-database-readonly.sh`. +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. +access and any custom grants before the final deny-all. Provide +`64-database-readonly-ppolicy-acl.ldif` or its `.ldif.template` variant through +the custom config directory to override the read-only restriction. ## data/ppolicy diff --git a/services/openldap-bootstrap/assets/ldif/config/database-admin/61-database-admin-ppolicy-acl.ldif.optional.template b/services/openldap-bootstrap/assets/ldif/config/database-admin/61-database-admin-ppolicy-acl.ldif.optional.template new file mode 100644 index 0000000..fdad5f2 --- /dev/null +++ b/services/openldap-bootstrap/assets/ldif/config/database-admin/61-database-admin-ppolicy-acl.ldif.optional.template @@ -0,0 +1,3 @@ +olcAccess: to attrs=pwdHistory + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" none + by * break diff --git a/services/openldap-bootstrap/assets/ldif/config/database-admin/62-database-admin-acl.ldif.template b/services/openldap-bootstrap/assets/ldif/config/database-admin/62-database-admin-acl.ldif.template new file mode 100644 index 0000000..deaa0db --- /dev/null +++ b/services/openldap-bootstrap/assets/ldif/config/database-admin/62-database-admin-acl.ldif.template @@ -0,0 +1,28 @@ +# Let directory clients retrieve the administrator's entry after binding. +olcAccess: to dn.base="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" attrs=userPassword,shadowLastChange + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" none + by * break +olcAccess: to dn.base="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" read + by * break +olcAccess: to dn.subtree="${OPENLDAP_BOOTSTRAP_DATA_TREE_SERVICES_DN}" + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" none + by * break +olcAccess: to dn.subtree="${OPENLDAP_BOOTSTRAP_DATA_TREE_USERS_DN}" attrs=userPassword + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" =wm + by * break +olcAccess: to attrs=userPassword,shadowLastChange + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" none + by * break +olcAccess: to dn.subtree="${OPENLDAP_BOOTSTRAP_DATA_TREE_USERS_DN}" + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" write + by * break +olcAccess: to dn.subtree="${OPENLDAP_BOOTSTRAP_DATA_TREE_GROUPS_DN}" + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" write + by * break +olcAccess: to dn.base="${OPENLDAP_BOOTSTRAP_SUFFIX}" attrs=entry,objectClass,o,dc + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" read + by * break +olcAccess: to * + by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" none + by * break diff --git a/services/openldap-bootstrap/assets/ldif/config/ppolicy-readonly/64-password-history-acl.ldif.template b/services/openldap-bootstrap/assets/ldif/config/database-readonly/64-database-readonly-ppolicy-acl.ldif.optional.template similarity index 100% rename from services/openldap-bootstrap/assets/ldif/config/ppolicy-readonly/64-password-history-acl.ldif.template rename to services/openldap-bootstrap/assets/ldif/config/database-readonly/64-database-readonly-ppolicy-acl.ldif.optional.template diff --git a/services/openldap-bootstrap/assets/ldif/data/database-admin/70-database-admin-account.ldif.template b/services/openldap-bootstrap/assets/ldif/data/database-admin/70-database-admin-account.ldif.template new file mode 100644 index 0000000..37a975c --- /dev/null +++ b/services/openldap-bootstrap/assets/ldif/data/database-admin/70-database-admin-account.ldif.template @@ -0,0 +1,6 @@ +dn: ${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN} +objectClass: top +objectClass: organizationalRole +objectClass: simpleSecurityObject +cn: ${OPENLDAP_BOOTSTRAP_DATA_ADMIN_CN} +userPassword: ${OPENLDAP_BOOTSTRAP_DATA_ADMIN_PASSWORD_HASHED} diff --git a/services/openldap-bootstrap/assets/scripts/20-database-readonly.sh b/services/openldap-bootstrap/assets/scripts/20-database-readonly.sh index 336ffe9..df0d5b2 100755 --- a/services/openldap-bootstrap/assets/scripts/20-database-readonly.sh +++ b/services/openldap-bootstrap/assets/scripts/20-database-readonly.sh @@ -33,6 +33,9 @@ if [ "${OPENLDAP_BOOTSTRAP_DATA_READONLY}" = "true" ]; then OUTPUT_DIR="$3" prepare_ldif_files "${SOURCE_DIR}/database-readonly" "${OUTPUT_DIR}" + if [ "${OPENLDAP_BOOTSTRAP_PPOLICY}" = "true" ] && [ -f "${OUTPUT_DIR}/64-database-readonly-ppolicy-acl.ldif.optional" ]; then + mv "${OUTPUT_DIR}/64-database-readonly-ppolicy-acl.ldif.optional" "${OUTPUT_DIR}/64-database-readonly-ppolicy-acl.ldif" + fi fi fi diff --git a/services/openldap-bootstrap/assets/scripts/23-database-admin.sh b/services/openldap-bootstrap/assets/scripts/23-database-admin.sh new file mode 100755 index 0000000..1e31d7d --- /dev/null +++ b/services/openldap-bootstrap/assets/scripts/23-database-admin.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# shellcheck disable=SC2034 +# shellcheck disable=SC1091 +# shellcheck disable=SC2153 + +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 + +if [ "${OPENLDAP_BOOTSTRAP_DATA_ADMIN}" = "true" ]; then + + source "/container/services/openldap-bootstrap/assets/scripts/helpers/common.sh" + + check_args "$@" + + TYPE="$1" + + if [ "${TYPE}" = "env" ]; then + ENV_FILE="$2" + + source "/container/services/openldap-bootstrap/assets/scripts/helpers/password.sh" + + OPENLDAP_BOOTSTRAP_DATA_ADMIN_CN=$(get_dn_attr cn "${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}") + + ensure_hashed_password OPENLDAP_BOOTSTRAP_DATA_ADMIN_PASSWORD_HASHED "database admin user" + + prepare_env_file "${ENV_FILE}" OPENLDAP_BOOTSTRAP_DATA_ADMIN_CN OPENLDAP_BOOTSTRAP_DATA_ADMIN_PASSWORD_HASHED + + elif [ "${TYPE}" = "ldif" ]; then + SOURCE_DIR="$2" + OUTPUT_DIR="$3" + + prepare_ldif_files "${SOURCE_DIR}/database-admin" "${OUTPUT_DIR}" + if [ "${OPENLDAP_BOOTSTRAP_PPOLICY}" = "true" ] && [ -f "${OUTPUT_DIR}/61-database-admin-ppolicy-acl.ldif.optional" ]; then + mv "${OUTPUT_DIR}/61-database-admin-ppolicy-acl.ldif.optional" "${OUTPUT_DIR}/61-database-admin-ppolicy-acl.ldif" + fi + fi + +fi diff --git a/services/openldap-bootstrap/assets/scripts/50-ppolicy.sh b/services/openldap-bootstrap/assets/scripts/50-ppolicy.sh index 76f571d..ab0741e 100755 --- a/services/openldap-bootstrap/assets/scripts/50-ppolicy.sh +++ b/services/openldap-bootstrap/assets/scripts/50-ppolicy.sh @@ -62,9 +62,6 @@ 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 diff --git a/services/openldap-bootstrap/assets/scripts/README.md b/services/openldap-bootstrap/assets/scripts/README.md index 76c57e8..a8c09da 100644 --- a/services/openldap-bootstrap/assets/scripts/README.md +++ b/services/openldap-bootstrap/assets/scripts/README.md @@ -15,13 +15,14 @@ Scripts are executed in numeric order. Most are feature-gated by an environment | `05-data-tree.sh` | `OPENLDAP_BOOTSTRAP_DATA_TREE=true` | Creates the users, groups and services OUs | | `10-tls.sh` | `OPENLDAP_BOOTSTRAP_TLS=true` | Applies TLS certificate and protocol configuration | | `15-tls-required.sh` | `OPENLDAP_BOOTSTRAP_TLS_REQUIRED=true` | Enforces minimum 128-bit SSF (warns if TLS is not also enabled) | -| `20-database-readonly.sh` | `OPENLDAP_BOOTSTRAP_DATA_READONLY=true` | Creates a read-only service account for the data database | +| `20-database-readonly.sh` | `OPENLDAP_BOOTSTRAP_DATA_READONLY=true` | Creates a read-only service account for the data database and hides password history when ppolicy is enabled | +| `23-database-admin.sh` | `OPENLDAP_BOOTSTRAP_DATA_ADMIN=true` | Creates a scoped users/groups administrator, protecting services, password hashes/history and configuration | | `25-database-management.sh` | `OPENLDAP_BOOTSTRAP_DATA_MANAGEMENT=true` | Creates one account below the services branch, reading users/groups and resetting user passwords with configurable hidden attributes and no other write permissions | | `30-monitor-readonly.sh` | `OPENLDAP_BOOTSTRAP_MONITOR_READONLY=true` | Creates a read-only service account for the monitor database | | `35-auditlog.sh` | `OPENLDAP_BOOTSTRAP_AUDITLOG=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 password policy, hides history from the data read-only account while preserving normal ACL evaluation, and validates inactivity and progressive lockout prerequisites | +| `50-ppolicy.sh` | `OPENLDAP_BOOTSTRAP_PPOLICY=true` | Configures password policy 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) | @@ -37,7 +38,7 @@ Utility functions shared across the bootstrap scripts. |----------|-------------| | `check_args` | Validates that a script was called with the expected operation (`env`/`ldif`) and argument count | | `prepare_env_file` | Appends exported variable assignments (safely shell-quoted) to an env file | -| `prepare_ldif_files` | Copies sources to a temporary writable directory (dereferencing symlinks), renders `.ldif.template` files via `container envsubst templates`, then copies all resulting `.ldif` files (sorted) to the output directory. Sources can be mounted read-only. The function removes its working copy on success; the bootstrap's global EXIT trap cleans up on failure. | +| `prepare_ldif_files` | Copies sources to a temporary writable directory (dereferencing symlinks), renders templates via `container envsubst templates`, then copies the resulting `.ldif` and `.ldif.optional` files (sorted) to the output directory. Feature scripts activate optional fragments with `mv`; the helper does not select them. Sources can be mounted read-only. The function removes its working copy on success; the bootstrap's global EXIT trap cleans up on failure. | | `get_dn_attr` | Extracts the value of a named attribute from a DN string (e.g. `cn` from `cn=admin,dc=example,dc=com`) | ### `helpers/password.sh` @@ -52,5 +53,5 @@ Utility functions shared across the bootstrap scripts. 1. Create a numbered script (e.g. `55-myfeature.sh`) that accepts `env` and `ldif` arguments. 2. Guard execution with the appropriate environment variable check. -3. Add the corresponding LDIF templates under `../ldif/config/` and/or `../ldif/data/`. +3. Add LDIF templates under matching feature directories in `../ldif/config/` and/or `../ldif/data/`: `55-myfeature.sh` loads `myfeature/`, with filenames such as `NN-myfeature-description.ldif.template`. Name conditional templates `.ldif.optional.template` and activate their rendered `.ldif.optional` files with `mv` in the feature script when needed. Unactivated fragments are ignored during final assembly. 4. Source `helpers/common.sh` and `helpers/password.sh` as needed. diff --git a/services/openldap-bootstrap/assets/scripts/helpers/common.sh b/services/openldap-bootstrap/assets/scripts/helpers/common.sh index 5f0bb8b..ce03e4a 100755 --- a/services/openldap-bootstrap/assets/scripts/helpers/common.sh +++ b/services/openldap-bootstrap/assets/scripts/helpers/common.sh @@ -56,7 +56,7 @@ prepare_ldif_files() { while IFS= read -r file; do cp -frav "${file}" "${output_dir}" 2>&1 | container log debug - done < <(find "${work_dir}" -type f -name "*.ldif" | sort -V) + done < <(find "${work_dir}" -type f \( -name "*.ldif" -o -name "*.ldif.optional" \) | sort -V) rm -rf "${work_dir:?}" }