feat(privacyidea): add guarded resolver reconciliation helper
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02929-244b-7391-b933-c04010e8eedb
This commit is contained in:
tegwick 2026-08-23 14:43:39 +02:00
parent edee5c28ab
commit f2e578cf3e
3 changed files with 137 additions and 0 deletions

View file

@ -126,6 +126,12 @@ material.
- A named operator window, driver, abort operator, provider authority, and
protected verification method are still required before any live action.
NetKingdom added
`sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh` as the bounded
resolver-only operation. It requires explicit `--apply` plus an interactive
terminal, passes password files by pathname to a child process, updates only
`lldap-coulomb`, and emits only a boolean result. It has not been run.
## Owner-reported cutover outcome
KeyCape reported on 2026-08-23 that its governed recovery path completed the

View file

@ -0,0 +1,125 @@
#!/usr/bin/env bash
# update-lldap-resolver-live.sh — attended, resolver-only privacyIDEA update.
#
# This does not repair realms or policies. It updates only the persisted
# lldap-coulomb resolver after the LLDAP bind credential has changed.
#
# Usage:
# ./update-lldap-resolver-live.sh --apply
#
# The operator supplies both passwords interactively. Values are kept in a
# mode-0700 temporary directory and mode-0600 files, passed to a child process
# by pathname, and never printed or placed in command arguments.
set -euo pipefail
if [[ "${1:-}" != "--apply" || "${2:-}" != "" ]]; then
echo "Usage: $0 --apply" >&2
exit 2
fi
if [[ ! -t 0 ]]; then
echo "ERROR: --apply requires an interactive terminal." >&2
exit 2
fi
PI_URL="${PI_URL:-https://pink.coulomb.social}"
RESOLVER_NAME="${RESOLVER_NAME:-lldap-coulomb}"
LLDAP_URL="${LLDAP_URL:-ldap://lldap.sso.svc.cluster.local:3890}"
LLDAP_BASE_DN="${LLDAP_BASE_DN:-dc=netkingdom,dc=local}"
LLDAP_BIND_DN="${LLDAP_BIND_DN:-uid=admin,ou=people,dc=netkingdom,dc=local}"
tmp="$(mktemp -d)"
chmod 700 "$tmp"
cleanup() {
if [[ -f "$tmp/pi-admin" ]]; then shred -u "$tmp/pi-admin" 2>/dev/null || rm -f "$tmp/pi-admin"; fi
if [[ -f "$tmp/lldap-bind" ]]; then shred -u "$tmp/lldap-bind" 2>/dev/null || rm -f "$tmp/lldap-bind"; fi
rmdir "$tmp" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
printf 'privacyIDEA pi-admin password: ' >&2
IFS= read -r -s pi_admin_password
printf '\n' >&2
printf 'LLDAP bind/admin password: ' >&2
IFS= read -r -s lldap_bind_password
printf '\n' >&2
if [[ -z "$pi_admin_password" || -z "$lldap_bind_password" ]]; then
echo "ERROR: passwords must not be empty." >&2
exit 2
fi
printf '%s' "$pi_admin_password" > "$tmp/pi-admin"
printf '%s' "$lldap_bind_password" > "$tmp/lldap-bind"
unset pi_admin_password lldap_bind_password
chmod 600 "$tmp/pi-admin" "$tmp/lldap-bind"
python3 - "$tmp/pi-admin" "$tmp/lldap-bind" "$PI_URL" "$RESOLVER_NAME" \
"$LLDAP_URL" "$LLDAP_BASE_DN" "$LLDAP_BIND_DN" <<'PY'
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
pi_path, ldap_path, base_url, resolver, ldap_url, base_dn, bind_dn = sys.argv[1:]
def read_secret(path: str) -> str:
value = Path(path).read_text(encoding="utf-8")
if not value:
raise RuntimeError("empty protected input")
return value
def post(path: str, payload: dict, token: str | None = None) -> dict:
headers = {"Content-Type": "application/json"}
if token is not None:
headers["Authorization"] = token
request = urllib.request.Request(
base_url.rstrip("/") + path,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.load(response)
except urllib.error.HTTPError as exc:
raise RuntimeError(f"HTTP status {exc.code}") from None
except (urllib.error.URLError, TimeoutError):
raise RuntimeError("request failed") from None
try:
auth = post("/auth", {"username": "pi-admin", "password": read_secret(pi_path)})
token = str(auth.get("result", {}).get("value", {}).get("token", ""))
if not token:
raise RuntimeError("privacyIDEA authentication failed")
resolver_body = {
"type": "ldapresolver",
"LDAPURI": ldap_url,
"BINDDN": bind_dn,
"BINDPW": read_secret(ldap_path),
"LDAPBASE": base_dn,
"LOGINNAMEATTRIBUTE": "uid",
"LDAPSEARCHFILTER": "(objectClass=inetOrgPerson)",
"LDAPFILTER": "(&(objectClass=inetOrgPerson)(uid=%s))",
"USERINFO": json.dumps({
"username": "uid",
"phone": "telephoneNumber",
"mobile": "mobile",
"email": "mail",
"surname": "sn",
"givenname": "givenName",
}),
"UIDTYPE": "uid",
"NOREFERRALS": True,
"NOSCHEMAS": True,
}
result = post(f"/resolver/{resolver}", resolver_body, token)
status = result.get("result", {}).get("status")
if status not in (True, "true", "True"):
raise RuntimeError("privacyIDEA resolver update was rejected")
except RuntimeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)
print("privacyIDEA resolver update: PASS")
PY

View file

@ -117,6 +117,12 @@ clients are a list, so the verifier now uses Authelia's supported template
filter over the Secret-mounted file. The revision was applied live and
Authelia returned Ready with startup complete.
NetKingdom also added the unattended-safe shape of the remaining provider
operation in `sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh`. It is
explicitly gated by `--apply`, requires an interactive terminal, uses protected
0600 files, updates only `lldap-coulomb`, and emits no credential values. It
has not been run; attended provider-admin reconciliation remains pending.
Do not use `sso-mfa/bootstrap/creds-rotate.sh` through an agent as currently
written: it prints generated replacement values and its signing-key path
causes immediate invalidation. Do not use a helper that reads the live Secret