3790e8001a
The Integrations page's Service Accounts (bind-only, organizationalRole)
don't cover the other real use case: an account something actually runs
as on a Linux host -- a media manager, a torrent client, Emby -- with a
real uidNumber/gidNumber that owns files, and a group other accounts
join for write access (e.g. a `stuff_manager` group granting write
rights to a media library). That needs a real posixAccount, which the
bind-only model can't be.
- New well-known group `app_sso_service_account`, seeded the same way as
app_sso_admin/app_sso_invite/app_sso_oauth_admin (docker-entrypoint.sh,
ops/ldap-setup.sh). Not a permission gate -- a marker.
- "Add new user" form gets a "This is a service account" checkbox: swaps
the person-shaped fields (first/last name, birthday, ToS agreement)
for a single account-name field, since none of those make sense for a
non-person account. On create, the route adds the user to
app_sso_service_account.
- User.listDetail() annotates each user with isServiceAccount (checked
against the marker group's member list once per call, not the memberof
overlay's reverse attribute -- not reliably returned by every LDAP
server this app might point at, confirmed against a real external
directory during testing). Users page shows a "service" badge.
- Notification broadcasts (filter_type=all/all_active) exclude service
accounts by default -- nobody reads mail as `stuff_manager`.
- Fixed a real, previously-unrelated bug this surfaced: addPosixAccount
unconditionally set `mail: data.mail` in the LDAP entry even when
undefined, and ldapts/slapd reject an attribute given an explicit
undefined value ("no values for attribute type") rather than treating
it as absent. This meant creating ANY user without an email already
failed outright -- not something a service account (which commonly has
no real mailbox) could route around. Made mail conditional, matching
how mobile/sshPublicKey/dob already work.
- docs/ldap.md now explains both kinds of service account side by side
and when to use which.
Verified end-to-end against a real external LDAP server (not a local
sandbox): created a service account with no email, confirmed it's
correctly flagged and excluded from broadcast recipient resolution,
confirmed a normal user is unaffected, confirmed the code degrades
gracefully if the marker group doesn't exist yet (pre-upgrade
deployments).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
303 lines
12 KiB
Bash
Executable File
303 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ldap-setup.sh — Idempotent OpenLDAP configuration for Theta42 SSO
|
|
#
|
|
# Must be run on the LDAP server (or a host with access to the local ldapi socket).
|
|
# cn=config changes use SASL EXTERNAL (run as root). Directory changes use bind DN + password.
|
|
#
|
|
# Usage:
|
|
# sudo ./ldap-setup.sh -p <admin-password>
|
|
# sudo ./ldap-setup.sh -p <admin-password> -b dc=theta42,dc=com -D cn=admin,dc=theta42,dc=com
|
|
#
|
|
# Options:
|
|
# -p PASSWORD LDAP admin password (required)
|
|
# -b BASE_DN Base DN (default: dc=theta42,dc=com)
|
|
# -D BIND_DN Admin bind DN (default: cn=admin,<BASE_DN>)
|
|
# -h Show this help
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────────────────
|
|
BASE_DN="dc=theta42,dc=com"
|
|
BIND_DN=""
|
|
ADMIN_PASS=""
|
|
|
|
usage() {
|
|
grep '^#' "$0" | sed 's/^# \{0,1\}//'
|
|
exit 0
|
|
}
|
|
|
|
while getopts "p:b:D:h" opt; do
|
|
case $opt in
|
|
p) ADMIN_PASS="$OPTARG" ;;
|
|
b) BASE_DN="$OPTARG" ;;
|
|
D) BIND_DN="$OPTARG" ;;
|
|
h) usage ;;
|
|
*) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$ADMIN_PASS" ]]; then
|
|
echo "Error: admin password is required (-p)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
[[ -z "$BIND_DN" ]] && BIND_DN="cn=admin,${BASE_DN}"
|
|
|
|
GROUP_BASE="ou=groups,${BASE_DN}"
|
|
POLICY_BASE="ou=policies,${BASE_DN}"
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
TMPFILE=$(mktemp /tmp/ldap-setup.XXXXXX.ldif)
|
|
trap 'rm -f "$TMPFILE"' EXIT
|
|
|
|
# Run an ldapsearch against cn=config (EXTERNAL/SASL — must be root)
|
|
# NOTE: ldapsearch echoes the search filter/base back as "# ..." comment lines.
|
|
# Those comments contain the very terms we grep for (e.g. "ppolicy"), which would
|
|
# make every existence check a false positive. Strip all comment lines so callers
|
|
# only ever see real result data.
|
|
config_search() {
|
|
ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "$@" 2>/dev/null | grep -v '^#'
|
|
}
|
|
|
|
# Run an ldapsearch against the directory (bind DN + password)
|
|
dir_search() {
|
|
ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$BASE_DN" "$@" 2>/dev/null | grep -v '^#'
|
|
}
|
|
|
|
# Apply an LDIF file via ldapadd against cn=config
|
|
config_add() {
|
|
printf '%s\n' "$1" > "$TMPFILE"
|
|
ldapadd -Q -Y EXTERNAL -H ldapi:/// -f "$TMPFILE"
|
|
}
|
|
|
|
# Apply an LDIF file via ldapadd against the directory
|
|
dir_add() {
|
|
printf '%s\n' "$1" > "$TMPFILE"
|
|
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -f "$TMPFILE"
|
|
}
|
|
|
|
ok() { echo " [ok] $*"; }
|
|
skip() { echo " [--] $* (already applied)"; }
|
|
info() { echo; echo "==> $*"; }
|
|
warn() { echo " [!!] $*" >&2; }
|
|
|
|
# ── Detect the database that serves BASE_DN ──────────────────────────────────
|
|
# The ppolicy overlay MUST be attached to the database that actually holds the
|
|
# user entries, otherwise pwdAccountLockedTime is never registered for them and
|
|
# the app's active/inactive toggle fails with "undefined attribute type".
|
|
# Do NOT hardcode olcDatabase={1}mdb — the index/backend varies per install.
|
|
info "locating user database for ${BASE_DN}"
|
|
|
|
DB_DN=$(config_search -b "cn=config" \
|
|
"(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn \
|
|
| awk '/^dn:/{sub(/^dn: /,""); print; exit}')
|
|
|
|
if [[ -z "$DB_DN" ]]; then
|
|
warn "Could not find a database with olcSuffix=${BASE_DN} under cn=config."
|
|
warn "Check the base DN (-b) and that slapd is running with a cn=config backend."
|
|
exit 1
|
|
fi
|
|
ok "user database: ${DB_DN}"
|
|
|
|
# ── 1. pw-sha2 module (SSHA512 password hashing) ─────────────────────────────
|
|
info "pw-sha2 module"
|
|
|
|
if config_search -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad | grep -q "^olcModuleLoad:.*pw-sha2"; then
|
|
skip "pw-sha2 already loaded"
|
|
else
|
|
config_add "dn: cn=module{0},cn=config
|
|
changetype: modify
|
|
add: olcModuleLoad
|
|
olcModuleLoad: pw-sha2"
|
|
ok "pw-sha2 loaded"
|
|
fi
|
|
|
|
# ── 2. ppolicy module ─────────────────────────────────────────────────────────
|
|
info "ppolicy module"
|
|
|
|
if config_search -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad | grep -q "^olcModuleLoad:.*ppolicy"; then
|
|
skip "ppolicy module already loaded"
|
|
else
|
|
config_add "dn: cn=module{0},cn=config
|
|
changetype: modify
|
|
add: olcModuleLoad
|
|
olcModuleLoad: ppolicy"
|
|
ok "ppolicy module loaded"
|
|
fi
|
|
|
|
# ── 3. ppolicy overlay ────────────────────────────────────────────────────────
|
|
info "ppolicy overlay"
|
|
|
|
if config_search -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn | grep -qi "^dn:.*ppolicy"; then
|
|
skip "ppolicy overlay already configured on ${DB_DN}"
|
|
else
|
|
config_add "dn: olcOverlay=ppolicy,${DB_DN}
|
|
objectClass: olcOverlayConfig
|
|
objectClass: olcPPolicyConfig
|
|
olcOverlay: ppolicy
|
|
olcPPolicyDefault: cn=ppolicy,${POLICY_BASE}
|
|
olcPPolicyUseLockout: TRUE
|
|
olcPPolicyHashCleartext: FALSE"
|
|
ok "ppolicy overlay added to ${DB_DN}"
|
|
fi
|
|
|
|
# ── 4. ppolicy schema ─────────────────────────────────────────────────────────
|
|
# The pwdPolicy objectClass and pwdAccountLockedTime attribute must be known to
|
|
# the RUNNING slapd (this is what User.setActive relies on).
|
|
#
|
|
# On OpenLDAP 2.5+ the ppolicy schema is BUILT INTO ppolicy.so and registered
|
|
# in memory when the module loads — it does NOT appear in cn=schema,cn=config,
|
|
# so we must check the live subschema (cn=Subschema) instead.
|
|
# On older builds the schema ships as a separate ppolicy.ldif that we load.
|
|
info "ppolicy schema"
|
|
|
|
# True if pwdPolicy is known to the running server right now.
|
|
ppolicy_schema_live() {
|
|
ldapsearch -Q -Y EXTERNAL -H ldapi:/// -s base -b "cn=Subschema" \
|
|
objectClasses attributeTypes 2>/dev/null | grep -qi "pwdPolicy"
|
|
}
|
|
|
|
if ppolicy_schema_live; then
|
|
skip "ppolicy schema present in running slapd"
|
|
else
|
|
# Try a shipped schema file (older OpenLDAP that doesn't build it into the module).
|
|
PPOLICY_LDIF=""
|
|
for candidate in \
|
|
/etc/ldap/schema/ppolicy.ldif \
|
|
/etc/openldap/schema/ppolicy.ldif \
|
|
/usr/share/doc/slapd/examples/schema/ppolicy.ldif \
|
|
/usr/local/etc/openldap/schema/ppolicy.ldif \
|
|
/usr/share/ldap/schema/ppolicy.ldif; do
|
|
[[ -f "$candidate" ]] && { PPOLICY_LDIF="$candidate"; break; }
|
|
done
|
|
|
|
if [[ -n "$PPOLICY_LDIF" ]]; then
|
|
ldapadd -Q -Y EXTERNAL -H ldapi:/// -f "$PPOLICY_LDIF"
|
|
ok "ppolicy schema loaded from $PPOLICY_LDIF"
|
|
else
|
|
# No schema file and not live: the module is meant to provide it built-in
|
|
# but the running slapd hasn't registered it. This almost always means the
|
|
# module was added to cn=config but slapd was never restarted to load it.
|
|
warn "ppolicy schema is NOT registered in the running slapd and no ppolicy.ldif"
|
|
warn "was found on disk. On OpenLDAP 2.5+ the schema is built into ppolicy.so"
|
|
warn "and is registered on module load. Restart slapd and re-run this script:"
|
|
warn " systemctl restart slapd && $0 -p '<admin-password>'"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# ── 5. Custom theta42 schema (dateOfBirth) ────────────────────────────────────
|
|
info "theta42 custom schema"
|
|
|
|
if config_search -b "cn=schema,cn=config" "(olcObjectClasses=*theta42Person*)" olcObjectClasses | grep -q "^olcObjectClasses:"; then
|
|
skip "theta42 schema already loaded"
|
|
else
|
|
config_add "dn: cn=theta42,cn=schema,cn=config
|
|
objectClass: olcSchemaConfig
|
|
cn: theta42
|
|
olcAttributeTypes: ( 1.3.6.1.4.1.99999.1.1
|
|
NAME 'dateOfBirth'
|
|
DESC 'Date of birth in ISO 8601 format YYYY-MM-DD'
|
|
EQUALITY caseExactMatch
|
|
SUBSTR caseExactSubstringsMatch
|
|
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
|
|
SINGLE-VALUE )
|
|
olcObjectClasses: ( 1.3.6.1.4.1.99999.2.1
|
|
NAME 'theta42Person'
|
|
DESC 'Theta42 SSO extended person attributes'
|
|
AUXILIARY
|
|
MAY ( dateOfBirth ) )"
|
|
ok "theta42 schema loaded"
|
|
fi
|
|
|
|
# ── 6. Policies OU ────────────────────────────────────────────────────────────
|
|
info "policies container (${POLICY_BASE})"
|
|
|
|
if dir_search -b "$POLICY_BASE" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
|
skip "${POLICY_BASE} already exists"
|
|
else
|
|
dir_add "dn: ${POLICY_BASE}
|
|
objectClass: organizationalUnit
|
|
ou: policies"
|
|
ok "${POLICY_BASE} created"
|
|
fi
|
|
|
|
# ── 7. Default ppolicy entry ──────────────────────────────────────────────────
|
|
info "default ppolicy entry"
|
|
|
|
if dir_search -b "cn=ppolicy,${POLICY_BASE}" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
|
skip "cn=ppolicy,${POLICY_BASE} already exists"
|
|
else
|
|
dir_add "dn: cn=ppolicy,${POLICY_BASE}
|
|
objectClass: top
|
|
objectClass: organizationalRole
|
|
objectClass: pwdPolicy
|
|
cn: ppolicy
|
|
pwdAttribute: 2.5.4.35
|
|
pwdLockout: FALSE
|
|
pwdMustChange: FALSE
|
|
pwdAllowUserChange: TRUE"
|
|
ok "default ppolicy created"
|
|
fi
|
|
|
|
# ── 8. Required SSO groups ────────────────────────────────────────────────────
|
|
info "required SSO groups"
|
|
|
|
for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
|
dn="cn=${group},${GROUP_BASE}"
|
|
if dir_search -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
|
skip "${dn} already exists"
|
|
else
|
|
dir_add "dn: ${dn}
|
|
objectClass: groupOfNames
|
|
objectClass: top
|
|
cn: ${group}
|
|
description: Theta42 SSO ${group} group
|
|
member: ${BIND_DN}"
|
|
ok "${dn} created"
|
|
fi
|
|
done
|
|
|
|
# ── 9. Verify ppolicy is actually active on the user database ─────────────────
|
|
# This is the exact condition the app relies on: if the ppolicy overlay is not
|
|
# attached to the database holding the users, modifying pwdAccountLockedTime
|
|
# fails and User.setActive() returns a 503.
|
|
info "verifying ppolicy is active on ${DB_DN}"
|
|
|
|
VERIFY_FAILED=0
|
|
|
|
if config_search -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn | grep -qi "^dn:.*ppolicy"; then
|
|
ok "ppolicy overlay is attached to the user database"
|
|
else
|
|
warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail"
|
|
VERIFY_FAILED=1
|
|
fi
|
|
|
|
# The authoritative test: is pwdAccountLockedTime known to the running slapd?
|
|
# This is the exact attribute User.setActive modifies.
|
|
if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -s base -b "cn=Subschema" attributeTypes 2>/dev/null \
|
|
| grep -qi "pwdAccountLockedTime"; then
|
|
ok "pwdAccountLockedTime attribute is registered (setActive will work)"
|
|
else
|
|
warn "pwdAccountLockedTime is NOT registered — setActive will return 503"
|
|
VERIFY_FAILED=1
|
|
fi
|
|
|
|
if dir_search -b "cn=ppolicy,${POLICY_BASE}" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
|
ok "default password policy entry exists"
|
|
else
|
|
warn "default ppolicy entry missing at cn=ppolicy,${POLICY_BASE}"
|
|
VERIFY_FAILED=1
|
|
fi
|
|
|
|
# ── Done ──────────────────────────────────────────────────────────────────────
|
|
echo
|
|
if [[ "$VERIFY_FAILED" -eq 0 ]]; then
|
|
echo "Setup complete. ppolicy is active — user active/inactive toggle will work."
|
|
else
|
|
echo "Setup finished WITH WARNINGS — see [!!] lines above. The app's" >&2
|
|
echo "active/inactive feature will not work until they are resolved." >&2
|
|
exit 1
|
|
fi
|