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.
This commit is contained in:
Bertrand Gouny
2026-10-27 13:05:36 +01:00
parent 02b5f0354d
commit 2761670739
19 changed files with 474 additions and 18 deletions
+10
View File
@@ -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;
+5 -2
View File
@@ -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**
+4
View File
@@ -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,
+109
View File
@@ -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
+2 -2
View File
@@ -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
+1
View File
@@ -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},
+171
View File
@@ -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 <<LDIF
dn: ${USER_DN}
objectClass: inetOrgPerson
uid: alice
cn: Alice
sn: User
displayName: Alice User
mail: alice@example.org
userPassword: ${TEST_PASSWORD_HASHED}
dn: ${GROUP_DN}
objectClass: groupOfNames
cn: team
member: ${USER_DN}
LDIF
expect_rc 0 bind_modify "${URI}" "${DIRECTORY_ADMIN_DN}" <<LDIF
dn: ${USER_DN}
changetype: modify
replace: mail
mail: updated@example.org
dn: ${GROUP_DN}
changetype: modify
replace: description
description: Updated by directory administrator
LDIF
expect_rc 0 bind_search "${URI}" "${DIRECTORY_ADMIN_DN}" -b "${USER_DN}" -s base mail userPassword
expect_output '^mail: updated@example.org$'
expect_no_output '^userPassword:'
expect_rc 0 bind_search "${URI}" "${DIRECTORY_ADMIN_DN}" -b "${GROUP_DN}" -s base description member
expect_output '^description: Updated by directory administrator$'
expect_output "^member: ${USER_DN}$"
expect_rc 50 ldapcompare -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" "${USER_DN}" "userPassword:${TEST_PASSWORD_HASHED}"
ok 'directory administrator creates and edits users/groups without hash read/compare'
expect_rc 0 ldappasswd -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" -s "${NEW_PASSWORD}" "${USER_DN}"
expect_rc 0 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w "${NEW_PASSWORD}"
expect_rc 0 bind_search "${URI}" "${ADMIN_DN}" -b "${USER_DN}" -s base userPassword
expect_output '^userPassword:: '
if [ "${TEST_ADMIN_MODE}" = "policy" ]; then
expect_rc 0 bind_modify "${URI}" "${DIRECTORY_ADMIN_DN}" <<LDIF
dn: ${USER_DN}
changetype: modify
replace: userPassword
userPassword: short
LDIF
expect_rc 0 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w short
expect_rc 0 bind_search "${URI}" "${ADMIN_DN}" -b "${USER_DN}" -s base pwdHistory
expect_output '^pwdHistory:'
expect_rc 0 bind_search "${URI}" "${DIRECTORY_ADMIN_DN}" -b "${USER_DN}" -s base userPassword pwdHistory
expect_no_output '^(userPassword|pwdHistory):'
expect_fail bind_modify "${URI}" "${DIRECTORY_ADMIN_DN}" <<LDIF
dn: ${USER_DN}
changetype: modify
delete: pwdHistory
LDIF
expect_rc 0 bind_search "${URI}" "cn=readonly,${TEST_SUFFIX}" -b "${USER_DN}" -s base cn pwdHistory
expect_output '^cn: Alice$'
expect_no_output '^pwdHistory:'
ok 'policy resets bypass quality checks while password history stays protected'
fi
expect_rc 0 ldappasswd -x -H "${URI}" -D "${TEST_MANAGEMENT_DN}" -w "${TEST_PASSWORD}" -s "${NEW_PASSWORD}" "${USER_DN}"
expect_rc 0 bind_search "${URI}" "${TEST_MANAGEMENT_DN}" -b "${USER_DN}" -s base mail
expect_output '^mail: updated@example.org$'
expect_rc 50 bind_modify "${URI}" "${TEST_MANAGEMENT_DN}" <<LDIF
dn: ${USER_DN}
changetype: modify
replace: mail
mail: forbidden@example.org
LDIF
expect_rc 0 ldappasswd -x -H "${URI}" -D "${USER_DN}" -w "${NEW_PASSWORD}" -a "${NEW_PASSWORD}" -s "Another-passw0rd-2026"
ok 'management permissions and normal user password changes remain available'
for dn in "${SERVICE_DN}" "${OUTSIDE_DN}"; do
cn="${dn%%,*}"
expect_rc 0 bind_modify "${URI}" "${ADMIN_DN}" -a <<LDIF
dn: ${dn}
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: ${cn#cn=}
userPassword: ${TEST_PASSWORD_HASHED}
LDIF
done
for dn in "${SERVICE_DN}" "${OUTSIDE_DN}" "${TEST_MANAGEMENT_DN}"; do
expect_rc 32 bind_search "${URI}" "${DIRECTORY_ADMIN_DN}" -b "${dn}" -s base '*' '+'
done
for dn in "${SERVICE_DN}" "${OUTSIDE_DN}" "${TEST_MANAGEMENT_DN}" "${DIRECTORY_ADMIN_DN}"; do
expect_fail ldappasswd -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" -s "${NEW_PASSWORD}" "${dn}"
expect_fail bind_modify "${URI}" "${DIRECTORY_ADMIN_DN}" <<LDIF
dn: ${dn}
changetype: modify
replace: description
description: Forbidden change
LDIF
expect_fail ldapdelete -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" "${dn}"
expect_rc 0 ldapwhoami -x -H "${URI}" -D "${dn}" -w "${TEST_PASSWORD}"
done
expect_fail bind_modify "${URI}" "${DIRECTORY_ADMIN_DN}" -a <<LDIF
dn: cn=forbidden,${TEST_SERVICES_DN}
objectClass: organizationalRole
cn: forbidden
LDIF
expect_fail ldapmodrdn -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" -r -s "${TEST_SERVICES_DN}" "${USER_DN}" uid=escaped
expect_fail ldapmodrdn -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" "${DIRECTORY_ADMIN_DN}" cn=renamed-admin
expect_rc 32 bind_search "${URI}" "${DIRECTORY_ADMIN_DN}" -b cn=config -s base '*'
expect_rc 50 bind_modify "${URI}" "${DIRECTORY_ADMIN_DN}" <<LDIF
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcRootPW
olcRootPW: forbidden
LDIF
ok 'administrator cannot change services, its own account, outside entries or configuration'
expect_rc 0 ldapmodrdn -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" -r "${USER_DN}" uid=alice-renamed
expect_rc 0 ldapmodrdn -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" -r "${GROUP_DN}" cn=renamed-team
expect_rc 0 bind_modify "${URI}" "${DIRECTORY_ADMIN_DN}" <<LDIF
dn: ${RENAMED_GROUP_DN}
changetype: modify
replace: member
member: ${RENAMED_DN}
LDIF
expect_rc 0 ldapdelete -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" "${RENAMED_GROUP_DN}"
expect_rc 0 ldapdelete -x -H "${URI}" -D "${DIRECTORY_ADMIN_DN}" -w "${TEST_PASSWORD}" "${RENAMED_DN}"
expect_rc 32 bind_search "${URI}" "${ADMIN_DN}" -b "${RENAMED_DN}" -s base dn
expect_rc 32 bind_search "${URI}" "${ADMIN_DN}" -b "${RENAMED_GROUP_DN}" -s base dn
expect_rc 0 bind_search "${URI}" "${CONFIG_ADMIN_DN}" -b cn=config -s base dn
expect_rc 0 bind_search "${URI}" "${ADMIN_DN}" -b "${SERVICE_DN}" -s base userPassword
expect_output '^userPassword:'
ok 'administrator renames/deletes users and groups; data/config root access stays intact'
+7
View File
@@ -15,15 +15,22 @@ trap 'chmod -R u+rwX "${test_dir:?}"; rm -rf "${test_dir:?}"' EXIT
mkdir -p "${test_dir}/source/..revision" "${test_dir}/output" "${test_dir}/custom"
printf 'dn: %s\n' "\${OPENLDAP_TEST_DN}" > "${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"
+4
View File
@@ -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=""
@@ -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,<suffix>` | 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,<suffix>` | Day-to-day user/group administration, for example through phpLDAPadmin; obeys the scoped ACLs |
| Management | `cn=management,ou=services,<suffix>` | 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
@@ -0,0 +1,3 @@
olcAccess: to attrs=pwdHistory
by dn.exact="${OPENLDAP_BOOTSTRAP_DATA_ADMIN_DN}" none
by * break
@@ -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
@@ -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}
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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:?}"
}