Validate cadence contract and require functional MFA verification
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-6-astra
Assistant-Session: 01a06ea3-7939-7b63-8125-699f8b50bedd
This commit is contained in:
tegwick 2026-09-05 01:28:05 +02:00
parent d4d61b722e
commit 4e07d60ff1
34 changed files with 1640 additions and 364 deletions

View file

@ -1,5 +1,7 @@
# T04 — Phase 3: Deploy privacyIDEA
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
Phase 3 of NK-WP-0001: deploys the MFA core (privacyIDEA) in the `mfa` namespace.
**Hostnames (config points CP-NK-002 / CP-NK-003):**

View file

@ -24,6 +24,8 @@
set -euo pipefail
PI_HELPER="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/pi_api.py"
NAMESPACE="mfa"
SECRETS_DIR="${1:-../../bootstrap/secrets}"
PI_URL="${2:-https://pink.coulomb.social}"
@ -46,8 +48,8 @@ LDAP_SIZELIMIT="${LDAP_SIZELIMIT:-500}"
PASS_COUNT=0
FAIL_COUNT=0
ok() { echo " [OK] $1"; ((PASS_COUNT++)); }
fail() { echo " [FAIL] $1"; ((FAIL_COUNT++)); }
ok() { echo " [OK] $1"; PASS_COUNT=$((PASS_COUNT + 1)); }
fail() { echo " [FAIL] $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
info() { echo " [INFO] $1"; }
# ── Validate secrets ──────────────────────────────────────────────────────────
@ -78,12 +80,10 @@ echo "Authenticating to privacyIDEA at $PI_URL ..."
if ! AUTH_RESPONSE=$(PI_ADMIN_PASS="$PI_ADMIN_PASS" python3 -c '
import json
import os
print()
print(json.dumps({"username": "pi-admin", "password": os.environ["PI_ADMIN_PASS"]}))
' | curl -sS -X POST "$PI_URL/auth" \
-H "Content-Type: application/json" \
--data-binary @- 2>/dev/null); then
echo "ERROR: Could not reach $PI_URL — is the cluster up and privacyIDEA running?" >&2
echo " Run verify-t04.sh to diagnose." >&2
' | python3 "$PI_HELPER" POST "$PI_URL/auth"); then
echo "ERROR: privacyIDEA authentication request failed." >&2
exit 1
fi
@ -92,26 +92,14 @@ PI_TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c \
if [[ -z "$PI_TOKEN" ]]; then
echo "ERROR: Authentication failed — check pi-admin credentials and MFA enrollment." >&2
echo " Response: $AUTH_RESPONSE" >&2
exit 1
fi
info "Authenticated as pi-admin (token obtained)"
pi_api() {
# pi_api <method> <path> [json-body]
# 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.
local method="$1"; local path="$2"; local body="${3:-}"
if [[ -n "$body" ]]; then
printf '%s' "$body" | curl -sf -X "$method" "$PI_URL$path" \
-H "Authorization: $PI_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- 2>/dev/null || echo "CURL_FAILED"
else
curl -sf -X "$method" "$PI_URL$path" \
-H "Authorization: $PI_TOKEN" \
2>/dev/null || echo "CURL_FAILED"
fi
local method="$1" path="$2" body="${3:-}"
{ printf '%s\n' "$PI_TOKEN"; printf '%s' "$body"; } |
python3 "$PI_HELPER" "$method" "$PI_URL$path" || true
}
check_result() {
@ -332,7 +320,7 @@ echo " WebUI → Config → Policies → mfa-passthru-phase1 → set activ
echo " Create a new policy: scope=authentication, action=otppin=tokenpin, realm=$REALM_NAME"
echo " This blocks login for users without an enrolled token."
echo ""
echo "Next step: ./verify-t06.sh"
echo "Next step: ../verify-t06.sh --user platform-root"
if [[ "$FAIL_COUNT" -gt 0 ]]; then
exit 1

View file

@ -0,0 +1,56 @@
"""Shared JSON request transport. Credentials stay in memory or stdin, never argv."""
import json
import sys
import urllib.error
import urllib.request
def request(url, payload=None, token=None, *, method=None):
headers = {}
data = None
if payload is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(payload).encode("utf-8")
if token:
headers["Authorization"] = token
# Bodyless GET must not advertise JSON: Werkzeug can reject it before routing.
req = urllib.request.Request(
url,
data=data,
headers=headers,
method=method or ("POST" if payload is not None else "GET"),
)
try:
with urllib.request.urlopen(req, timeout=20) as response:
status, body = response.status, response.read()
try:
return status, json.loads(body) if body else None
except (ValueError, UnicodeError):
return status, None
except urllib.error.HTTPError as exc:
exc.close()
return exc.code, None
except (urllib.error.URLError, TimeoutError, OSError):
return 0, None
def main():
# Shell adapter: token on the first line, optional JSON body on the rest.
token = sys.stdin.readline().rstrip("\n")
body = sys.stdin.read()
status, result = request(
sys.argv[2],
json.loads(body) if body else None,
token or None,
method=sys.argv[1],
)
if not 200 <= status < 300 or not isinstance(result, dict):
print("CURL_FAILED")
return 1
print(json.dumps(result))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -121,7 +121,8 @@ else
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_HELPER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
if PYTHONPATH="$PI_HELPER_DIR${PYTHONPATH:+:$PYTHONPATH}" 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" "$PREDECESSOR" \
"$LDAP_TIMEOUT" "$LDAP_CACHE_TIMEOUT" "$LDAP_SIZELIMIT" <<'PY'
@ -148,33 +149,7 @@ def secret(path: str) -> str:
raise RuntimeError("empty protected input")
return value
def request(url: str, payload: dict | None = None, token: str | None = None) -> tuple[int, dict | None]:
# 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
req = urllib.request.Request(url, data=data, headers=headers, method="POST" if payload is not None else "GET")
try:
with urllib.request.urlopen(req, timeout=20) as response:
status = response.status
body = response.read()
if not body:
return status, None
try:
return status, json.loads(body)
except json.JSONDecodeError:
return status, None
except urllib.error.HTTPError as exc:
return exc.code, None
except (urllib.error.URLError, TimeoutError):
return 0, None
from pi_api import request
def check_k8s_ready() -> None:
workloads = (("sso", "lldap"), ("mfa", "privacyidea"), ("sso", "keycape"),

View file

@ -40,17 +40,6 @@ printf "LLDAP_LDAP_USER_PASS=%q\n" "$LLDAP_LDAP_USER_PASS" > "$tmp/lldap/secrets
bash "$SCRIPT_DIR/bootstrap-realm.sh" "$tmp" "$PI_URL"
if ! bash "$SSO_MFA_K8S_DIR/verify-t06.sh" "$tmp"; then
cat >&2 <<'WARN'
[WARN] verify-t06 still reports failures. If realm, resolver, policies, and
self-service pass but KeyCape token checks fail, run the KeyCape privacyIDEA
MFA token repair action after platform-root enrollment.
WARN
fi
cat <<'OK'
[OK] privacyIDEA coulomb realm repair command finished. Enroll or re-enroll
platform-root TOTP in privacyIDEA next.
OK
echo "Realm configuration applied; functional verification requires an enrolled OTP token."
bash "$SSO_MFA_K8S_DIR/verify-t06.sh" --pi-url "$PI_URL" --user "${MFA_USER:-platform-root}"
echo "[OK] realm repair and functional MFA verification passed."

View file

@ -0,0 +1,128 @@
"""Attended functional privacyIDEA proof for verify-t06.sh."""
import argparse
import getpass
import json
import sys
import urllib.parse
from pi_api import request
def value(status, body):
if status != 200 or not isinstance(body, dict):
raise ValueError("request failed")
result = body.get("result")
if not isinstance(result, dict) or result.get("status") is not True:
raise ValueError("API operation failed")
return result.get("value")
def verify(url, user, realm, resolver, password, otp_prompt):
phase = "authentication"
try:
auth = value(
*request(url + "/auth", {"username": "pi-admin", "password": password})
)
token = auth.get("token") if isinstance(auth, dict) else None
if not isinstance(token, str) or not token:
raise ValueError("admin token missing")
phase = "realm-binding"
realms = value(*request(url + "/realm/", token=token))
bindings = realms[realm]["resolver"]
if not any(
isinstance(item, dict) and item.get("name") == resolver for item in bindings
):
raise ValueError("realm not bound to expected resolver")
phase = "resolver-tuning"
resolvers = value(
*request(
url + "/resolver/" + urllib.parse.quote(resolver, safe=""), token=token
)
)
config = resolvers[resolver]
if config.get("type") != "ldapresolver":
raise ValueError("wrong resolver type")
data = config["data"]
for name in ("TIMEOUT", "CACHE_TIMEOUT", "SIZELIMIT"):
number = data.get(name)
if (
isinstance(number, bool)
or not isinstance(number, (str, int))
or not str(number).isascii()
or not str(number).isdigit()
):
raise ValueError("missing or invalid numeric parameter")
phase = "known-user-lookup"
query = urllib.parse.urlencode({"realm": realm, "username": user})
users = value(*request(url + "/user/?" + query, token=token))
if not isinstance(users, list) or not any(
isinstance(item, dict)
and item.get("username") == user
and item.get("resolver") == resolver
for item in users
):
raise ValueError("known user did not resolve through expected resolver")
phase = "mfa-validation"
otp = otp_prompt()
if not otp:
raise ValueError("MFA input missing")
status, body = request(
url + "/validate/check", {"user": user, "realm": realm, "pass": otp}, token
)
# passthru can return value=true for token-less users. Require a token
# serial and type in the successful validation, not password-only success.
if value(status, body) is not True:
raise ValueError("MFA denied")
detail = body.get("detail", {})
if not detail.get("serial") or detail.get("type") not in {"totp", "hotp"}:
raise ValueError("no token-backed MFA proof")
except (OSError, ValueError, TypeError, KeyError, AttributeError, EOFError):
return {"result": "FAIL", "phase": phase}
return {
"result": "PASS",
"phase": "complete",
"proofs": [
"realm-binding",
"resolver-tuning",
"known-user-lookup",
"token-backed-mfa",
],
}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pi-url", default="https://pink.coulomb.social")
parser.add_argument("--user", required=True)
parser.add_argument("--realm", default="coulomb")
parser.add_argument("--resolver", default="lldap-coulomb")
args = parser.parse_args(argv)
if not sys.stdin.isatty():
print(
"T06 requires an attended terminal for protected password and fresh MFA input.",
file=sys.stderr,
)
return 2
if urllib.parse.urlsplit(args.pi_url).scheme != "https":
parser.error("--pi-url must use HTTPS")
try:
password = getpass.getpass("privacyIDEA pi-admin password: ")
if not password:
raise ValueError("empty password")
report = verify(
args.pi_url.rstrip("/"),
args.user,
args.realm,
args.resolver,
password,
lambda: getpass.getpass("Fresh MFA code (include token PIN if required): "),
)
except (EOFError, KeyboardInterrupt, ValueError):
report = {"result": "FAIL", "phase": "protected-input"}
print(json.dumps(report, sort_keys=True))
return 0 if report["result"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())