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 - A named operator window, driver, abort operator, provider authority, and
protected verification method are still required before any live action. 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 ## Owner-reported cutover outcome
KeyCape reported on 2026-08-23 that its governed recovery path completed the 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 filter over the Secret-mounted file. The revision was applied live and
Authelia returned Ready with startup complete. 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 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 written: it prints generated replacement values and its signing-key path
causes immediate invalidation. Do not use a helper that reads the live Secret causes immediate invalidation. Do not use a helper that reads the live Secret