diff --git a/sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh b/sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh index 5f75f36..b62ccc5 100755 --- a/sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh +++ b/sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh @@ -8,16 +8,18 @@ # manifest. # # Usage: -# ./reconcile-lldap-resolver-live.sh --apply +# ./reconcile-lldap-resolver-live.sh --check # read-only proof +# ./reconcile-lldap-resolver-live.sh --apply # resolver update + proof set -euo pipefail -if [[ "${1:-}" != "--apply" || "${2:-}" != "" ]]; then - echo "Usage: $0 --apply" >&2 +MODE="${1:-}" +if [[ "$MODE" != "--apply" && "$MODE" != "--check" || "${2:-}" != "" ]]; then + echo "Usage: $0 --check|--apply" >&2 exit 2 fi if [[ ! -t 0 ]]; then - echo "ERROR: --apply requires an interactive terminal." >&2 + echo "ERROR: --check/--apply requires an interactive terminal." >&2 exit 2 fi @@ -34,7 +36,7 @@ KEYCAPE_DISCOVERY_URL="${KEYCAPE_DISCOVERY_URL:-https://kc.coulomb.social/.well- tmp="$(mktemp -d)" chmod 700 "$tmp" cleanup() { - for file in pi-admin lldap-new lldap-old otp; do + for file in pi-admin lldap-new lldap-old otp phase; do if [[ -f "$tmp/$file" ]]; then shred -u "$tmp/$file" 2>/dev/null || rm -f "$tmp/$file" fi @@ -65,19 +67,23 @@ 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" <<'PY' + "$RESOLVER_NAME" "$MFA_USER" "$MFA_REALM" "$KEYCAPE_DISCOVERY_URL" "$MODE" "$tmp/phase" <<'PY' import json import subprocess import sys import urllib.error +import urllib.parse import urllib.request 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, + base_dn, bind_dn, resolver_name, mfa_user, mfa_realm, discovery_url, mode, phase_path, ) = sys.argv[1:] +def phase(name: str) -> None: + Path(phase_path).write_text(name, encoding="ascii") + def secret(path: str) -> str: value = Path(path).read_text(encoding="utf-8") if not value: @@ -133,14 +139,27 @@ def lldap_login(password: str) -> tuple[int, bool]: return status, status == 200 and isinstance(body, dict) and bool(body.get("token")) try: + phase("preflight") check_k8s_ready() check_health() + phase("privacyidea-auth") status, auth = request(pi_url + "/auth", {"username": "pi-admin", "password": secret(pi_path)}) pi_token = str((auth or {}).get("result", {}).get("value", {}).get("token", "")) if status != 200 or not pi_token: raise RuntimeError("privacyIDEA authentication failed") + # Prove the provider-approved replacement and predecessor disposition + # before any resolver mutation is attempted. + phase("replacement-lldap-auth") + 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") + resolver_body = { "type": "ldapresolver", "LDAPURI": lldap_url, "BINDDN": bind_dn, "BINDPW": secret(new_path), "LDAPBASE": base_dn, @@ -149,15 +168,28 @@ try: "USERINFO": json.dumps({"username": "uid", "phone": "telephoneNumber", "mobile": "mobile", "email": "mail", "surname": "sn", "givenname": "givenName"}), "UIDTYPE": "uid", "NOREFERRALS": True, "NOSCHEMAS": True, } - status, result = request(pi_url + "/resolver/" + resolver_name, resolver_body, pi_token) - if status != 200 or (result or {}).get("result", {}).get("status") not in (True, "true", "True"): - raise RuntimeError("resolver update failed") + if mode == "--apply": + phase("resolver-update") + status, result = request(pi_url + "/resolver/" + resolver_name, resolver_body, pi_token) + if status != 200 or (result or {}).get("result", {}).get("status") not in (True, "true", "True"): + raise RuntimeError("resolver update failed") + else: + phase("resolver-read-only-check") - status, result = request(pi_url + f"/user/?realm={mfa_realm}&pagesize=1", token=pi_token) + phase("resolver-lookup") + status, result = request( + 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", []) - if status != 200 or not users: + if status != 200: + phase(f"resolver-lookup-http-{status}") + raise RuntimeError("replacement resolver lookup failed") + if not users: + phase("resolver-lookup-empty") raise RuntimeError("replacement resolver lookup failed") + phase("privacyidea-mfa") status, result = request( pi_url + "/validate/check", {"user": mfa_user, "realm": mfa_realm, "pass": secret(otp_path)}, @@ -166,17 +198,10 @@ try: if status != 200 or (result or {}).get("result", {}).get("value") is not True: raise RuntimeError("replacement MFA validation failed") - new_status, new_authenticated = lldap_login(secret(new_path)) - if new_status != 200 or not new_authenticated: - raise RuntimeError("replacement LLDAP authentication failed") - 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") - + phase("postflight") check_k8s_ready() check_health() except (OSError, RuntimeError, subprocess.SubprocessError) as exc: - print("NK-WP-0033 receipt FAIL: proof checks did not pass", file=sys.stderr) raise SystemExit(1) PY then @@ -185,14 +210,19 @@ else rc=$? fi +phase_result="$(cat "$tmp/phase" 2>/dev/null || echo unknown)" cleanup trap - EXIT INT TERM if [[ "$rc" -ne 0 ]]; then - echo "NK-WP-0033 receipt FAIL: proof checks did not pass; cleanup=PASS" >&2 + echo "NK-WP-0033 receipt FAIL: phase=$phase_result; cleanup=PASS" >&2 exit "$rc" fi if [[ -d "$tmp" ]]; then echo "NK-WP-0033 receipt FAIL: cleanup=FAIL" >&2 exit 1 fi -echo "NK-WP-0033 receipt PASS: resolver lookup, privacyIDEA MFA, predecessor denial, readiness, health, cleanup=PASS" +if [[ "$MODE" == "--check" ]]; then + echo "NK-WP-0033 receipt PASS: read-only resolver lookup, privacyIDEA MFA, predecessor denial, readiness, health, cleanup=PASS" +else + echo "NK-WP-0033 receipt PASS: resolver lookup, privacyIDEA MFA, predecessor denial, readiness, health, cleanup=PASS" +fi