fix(privacyidea): repair the resolver reconciliation script (NK-WP-0033)

reconcile-lldap-resolver-live.sh had never completed a run. Four defects,
found by running it on 2026-08-27:

1. request() set Content-Type: application/json on every call, including
   bodyless GETs. Werkzeug 3.x rejects those in front of privacyIDEA, so
   every GET returned an HTML 400 while POSTs succeeded — the resolver
   write landed and the lookup immediately after it did not.
   bootstrap-realm.sh already fixed this in pi_api and said why; this
   script was written later and did not inherit it.

2. GET /user/ returns result.value as a list of user objects, not a dict
   carrying "users". With the 400 fixed, the lookup finally reached the
   parse and raised AttributeError past the except clause, so the run
   died as a traceback instead of a receipt. Both shapes now accepted,
   and the except clause catches parse errors so a failed run still
   names the phase it died in.

3. A resolver write replaces the whole object, so TIMEOUT,
   CACHE_TIMEOUT and SIZELIMIT were dropped by every --apply. A resolver
   with them unset still resolves users, but the WebUI refuses to save
   or test it — so the script silently un-repaired a resolver an
   operator had fixed by hand. Now sent, defaulting to the verified
   5/120/500 and overridable per run. Same omission fixed in
   bootstrap-realm.sh, which created the resolver that way originally.

4. The predecessor prompt could not be left empty, so an operator who
   had lost the exposed credential had to type a placeholder — which
   also fails the bind and was recorded as a PASSING denial proof.
   --predecessor-unavailable skips the bind and records NOT-PROVEN.
   --note carries operator context into the receipt line itself, so the
   claim and its caveat travel together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
This commit is contained in:
tegwick 2026-08-27 22:20:03 +02:00
parent 7ce02957b3
commit 4a38511d11
2 changed files with 117 additions and 17 deletions

View file

@ -35,6 +35,13 @@ REALM_NAME="coulomb"
LLDAP_URL="ldap://lldap.sso.svc.cluster.local:3890"
LLDAP_BASE_DN="dc=netkingdom,dc=local"
LLDAP_BIND_DN="uid=admin,ou=people,dc=netkingdom,dc=local"
# Numeric resolver parameters. A resolver with these unset still resolves
# users, but the WebUI refuses to save or test it until they are filled in by
# hand, and a write that omits them drops whatever was there (NK-WP-0033,
# 2026-08-27).
LDAP_TIMEOUT="${LDAP_TIMEOUT:-5}"
LDAP_CACHE_TIMEOUT="${LDAP_CACHE_TIMEOUT:-120}"
LDAP_SIZELIMIT="${LDAP_SIZELIMIT:-500}"
PASS_COUNT=0
FAIL_COUNT=0
@ -152,7 +159,10 @@ body = {
'USERINFO': json.dumps({\"username\": \"uid\", \"phone\": \"telephoneNumber\", \"mobile\": \"mobile\", \"email\": \"mail\", \"surname\": \"sn\", \"givenname\": \"givenName\"}),
'UIDTYPE': 'uid',
'NOREFERRALS': True,
'NOSCHEMAS': True
'NOSCHEMAS': True,
'TIMEOUT': int('$LDAP_TIMEOUT'),
'CACHE_TIMEOUT': int('$LDAP_CACHE_TIMEOUT'),
'SIZELIMIT': int('$LDAP_SIZELIMIT')
}
print(json.dumps(body))
")

View file

@ -10,12 +10,50 @@
# Usage:
# ./reconcile-lldap-resolver-live.sh --check # read-only proof
# ./reconcile-lldap-resolver-live.sh --apply # resolver update + proof
#
# Options:
# --note TEXT operator context recorded verbatim in the receipt
# --predecessor-unavailable the exposed predecessor cannot be produced; the
# denial bind is not attempted and the receipt
# records it as NOT-PROVEN
set -euo pipefail
MODE="${1:-}"
if [[ "$MODE" != "--apply" && "$MODE" != "--check" || "${2:-}" != "" ]]; then
echo "Usage: $0 --check|--apply" >&2
MODE=""
NOTE=""
PREDECESSOR="required"
usage() {
echo "Usage: $0 --check|--apply [--note TEXT] [--predecessor-unavailable]" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--check|--apply)
if [[ -n "$MODE" ]]; then usage; fi
MODE="$1"; shift ;;
--note)
# Never put a credential here: the receipt goes to a terminal and
# is copied into the incident record.
if [[ -z "${2:-}" ]]; then usage; fi
NOTE="$2"; shift 2 ;;
--predecessor-unavailable)
# Typing a placeholder at the predecessor prompt also fails the
# bind, and would be recorded as a passing denial proof. An absent
# proof is honest; a fabricated one asserts a test that never ran.
PREDECESSOR="unavailable"; shift ;;
*)
usage ;;
esac
done
if [[ "$MODE" != "--apply" && "$MODE" != "--check" ]]; then
usage
fi
# One bounded line: a receipt that breaks its own format is not evidence
# anyone can read back.
NOTE="${NOTE//$'\n'/ }"
NOTE="${NOTE//$'\r'/ }"
if (( ${#NOTE} > 200 )); then
echo "ERROR: --note must be 200 characters or fewer." >&2
exit 2
fi
if [[ ! -t 0 ]]; then
@ -32,6 +70,20 @@ RESOLVER_NAME="${RESOLVER_NAME:-lldap-coulomb}"
MFA_USER="${MFA_USER:-platform-root}"
MFA_REALM="${MFA_REALM:-coulomb}"
KEYCAPE_DISCOVERY_URL="${KEYCAPE_DISCOVERY_URL:-https://kc.coulomb.social/.well-known/openid-configuration}"
# A resolver write replaces the whole object, so every field the body omits is
# dropped. A resolver with these unset still resolves users, but the WebUI
# refuses to save or test it until they are filled in — so omitting them here
# silently un-repairs a resolver an operator fixed by hand.
LDAP_TIMEOUT="${LDAP_TIMEOUT:-5}"
LDAP_CACHE_TIMEOUT="${LDAP_CACHE_TIMEOUT:-120}"
LDAP_SIZELIMIT="${LDAP_SIZELIMIT:-500}"
for _v in LDAP_TIMEOUT LDAP_CACHE_TIMEOUT LDAP_SIZELIMIT; do
if [[ ! "${!_v}" =~ ^[0-9]+$ ]]; then
echo "ERROR: $_v must be a non-negative integer, got '${!_v}'." >&2
exit 2
fi
done
unset _v
tmp="$(mktemp -d)"
chmod 700 "$tmp"
@ -62,12 +114,17 @@ prompt_secret() {
prompt_secret "privacyIDEA pi-admin password" "$tmp/pi-admin"
prompt_secret "replacement LLDAP bind/admin password" "$tmp/lldap-new"
prompt_secret "exposed predecessor LLDAP password (denial proof only)" "$tmp/lldap-old"
if [[ "$PREDECESSOR" == "required" ]]; then
prompt_secret "exposed predecessor LLDAP password (denial proof only)" "$tmp/lldap-old"
else
echo " [INFO] predecessor unavailable: denial proof recorded as NOT-PROVEN." >&2
fi
prompt_secret "one-time MFA code for $MFA_USER@$MFA_REALM" "$tmp/otp"
if python3 - "$tmp/pi-admin" "$tmp/lldap-new" "$tmp/lldap-old" "$tmp/otp" \
"$PI_URL" "$LLDAP_AUTH_URL" "$LLDAP_URL" "$LLDAP_BASE_DN" "$LLDAP_BIND_DN" \
"$RESOLVER_NAME" "$MFA_USER" "$MFA_REALM" "$KEYCAPE_DISCOVERY_URL" "$MODE" "$tmp/phase" <<'PY'
"$RESOLVER_NAME" "$MFA_USER" "$MFA_REALM" "$KEYCAPE_DISCOVERY_URL" "$MODE" "$tmp/phase" "$PREDECESSOR" \
"$LDAP_TIMEOUT" "$LDAP_CACHE_TIMEOUT" "$LDAP_SIZELIMIT" <<'PY'
import json
import subprocess
import sys
@ -79,6 +136,7 @@ from pathlib import Path
(
pi_path, new_path, old_path, otp_path, pi_url, lldap_auth_url, lldap_url,
base_dn, bind_dn, resolver_name, mfa_user, mfa_realm, discovery_url, mode, phase_path,
predecessor, ldap_timeout, ldap_cache_timeout, ldap_sizelimit,
) = sys.argv[1:]
def phase(name: str) -> None:
@ -91,7 +149,14 @@ def secret(path: str) -> str:
return value
def request(url: str, payload: dict | None = None, token: str | None = None) -> tuple[int, dict | None]:
headers = {"Content-Type": "application/json"}
# Content-Type is only set on requests with a body — Werkzeug 3.x raises
# BadRequest if Content-Type: application/json is sent on a bodyless GET,
# and the rejection happens in front of privacyIDEA, so the reply is an HTML
# error page rather than a JSON result. Same fix as bootstrap-realm.sh's
# pi_api helper.
headers = {}
if payload is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = token
data = json.dumps(payload).encode("utf-8") if payload is not None else None
@ -155,10 +220,15 @@ try:
new_status, new_authenticated = lldap_login(secret(new_path))
if new_status != 200 or not new_authenticated:
raise RuntimeError("replacement LLDAP authentication failed")
phase("predecessor-denial")
old_status, old_authenticated = lldap_login(secret(old_path))
if old_status not in (401, 403) or old_authenticated:
raise RuntimeError("predecessor LLDAP authentication was not denied")
if predecessor == "unavailable":
# Not a pass. The bind is not attempted, so this run claims nothing
# about the predecessor's disposition.
phase("predecessor-denial-not-proven")
else:
phase("predecessor-denial")
old_status, old_authenticated = lldap_login(secret(old_path))
if old_status not in (401, 403) or old_authenticated:
raise RuntimeError("predecessor LLDAP authentication was not denied")
resolver_body = {
"type": "ldapresolver", "LDAPURI": lldap_url, "BINDDN": bind_dn,
@ -167,6 +237,8 @@ try:
"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,
"TIMEOUT": int(ldap_timeout), "CACHE_TIMEOUT": int(ldap_cache_timeout),
"SIZELIMIT": int(ldap_sizelimit),
}
if mode == "--apply":
phase("resolver-update")
@ -181,7 +253,13 @@ try:
pi_url + f"/user/?realm={mfa_realm}&username={urllib.parse.quote(mfa_user)}",
token=pi_token,
)
users = (result or {}).get("result", {}).get("value", {}).get("users", [])
# privacyIDEA returns result.value as a list of user objects for /user/.
# Accept the dict-with-"users" shape too: other endpoints use it, and a
# lookup that guesses wrong raises AttributeError past the except clause.
_value = (result or {}).get("result", {}).get("value", [])
if isinstance(_value, dict):
_value = _value.get("users", [])
users = _value if isinstance(_value, list) else []
if status != 200:
phase(f"resolver-lookup-http-{status}")
raise RuntimeError("replacement resolver lookup failed")
@ -201,7 +279,10 @@ try:
phase("postflight")
check_k8s_ready()
check_health()
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
except (OSError, RuntimeError, subprocess.SubprocessError,
AttributeError, TypeError, KeyError, ValueError) as exc:
# A malformed reply must still produce a receipt naming the phase it died
# in. A traceback tells the operator nothing about what was proven.
raise SystemExit(1)
PY
then
@ -211,18 +292,27 @@ else
fi
phase_result="$(cat "$tmp/phase" 2>/dev/null || echo unknown)"
if [[ "$PREDECESSOR" == "required" ]]; then
pred_text="predecessor denial"
else
pred_text="predecessor denial=NOT-PROVEN"
fi
note_text=""
if [[ -n "$NOTE" ]]; then
note_text="; note=$NOTE"
fi
cleanup
trap - EXIT INT TERM
if [[ "$rc" -ne 0 ]]; then
echo "NK-WP-0033 receipt FAIL: phase=$phase_result; cleanup=PASS" >&2
echo "NK-WP-0033 receipt FAIL: phase=$phase_result; $pred_text; cleanup=PASS$note_text" >&2
exit "$rc"
fi
if [[ -d "$tmp" ]]; then
echo "NK-WP-0033 receipt FAIL: cleanup=FAIL" >&2
echo "NK-WP-0033 receipt FAIL: cleanup=FAIL$note_text" >&2
exit 1
fi
if [[ "$MODE" == "--check" ]]; then
echo "NK-WP-0033 receipt PASS: read-only resolver lookup, privacyIDEA MFA, predecessor denial, readiness, health, cleanup=PASS"
echo "NK-WP-0033 receipt PASS: read-only resolver lookup, privacyIDEA MFA, $pred_text, readiness, health, cleanup=PASS$note_text"
else
echo "NK-WP-0033 receipt PASS: resolver lookup, privacyIDEA MFA, predecessor denial, readiness, health, cleanup=PASS"
echo "NK-WP-0033 receipt PASS: resolver lookup, privacyIDEA MFA, $pred_text, readiness, health, cleanup=PASS$note_text"
fi