NK-WP-0033 add attended resolver reconciliation receipt
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 15:18:29 +02:00
parent eec7007c21
commit 5b0a521c9a
3 changed files with 225 additions and 24 deletions

View file

@ -2,7 +2,7 @@
Incident: `KEYCAPE-EXPOSURE-20260823-01`
Workplan: `NK-WP-0033`
NetKingdom procedure revision: `f2e578c`
NetKingdom procedure: `sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh` (revision pinned by the approved checkout)
Platform recovery contract: railiance-platform `453fed3`
Owner cutover receipt: State Hub message `45b236c8-052f-43d3-a472-44f8e9694da2`
@ -19,7 +19,7 @@ State Hub, Git, chat, command arguments, or ordinary logs.
The operator must record a private approval receipt containing, at minimum:
- incident `KEYCAPE-EXPOSURE-20260823-01`;
- NetKingdom revision `f2e578c` and platform contract `453fed3`;
- the NetKingdom revision containing `reconcile-lldap-resolver-live.sh` and platform contract `453fed3`;
- the owner receipt `45b236c8-052f-43d3-a472-44f8e9694da2`;
- the exact start/end window, attended driver, and independent abort operator;
- confirmation that the replacement LLDAP credential is the provider-approved
@ -33,35 +33,36 @@ identifier differs from the receipt.
Run from the approved operator workstation, with the cluster context and
provider endpoint already authorized. Do not render any Secret data.
1. Verify the checked-out revision is exactly `f2e578c` and the helper has mode
`0755`; inspect its source, not live credential material.
1. Verify the approved checkout contains `reconcile-lldap-resolver-live.sh`
with mode `0755`; inspect its source, not live credential material.
2. Confirm LLDAP, privacyIDEA, KeyCape, Authelia, and
identity-provisioner are Ready using deployment/pod status fields only.
3. Confirm privacyIDEA and KeyCape health endpoints return an HTTP success
status, discarding response bodies. Do not use a command that prints a
bearer token or configuration response.
3. Confirm privacyIDEA availability via the documented unauthenticated
`/token/` probe (401/403 is expected) and KeyCape discovery health,
discarding response bodies. Do not use a command that prints a bearer token
or configuration response.
4. Confirm the approved window, driver, abort operator, provider custody, and
cleanup workspace are ready. Stop on any drift or missing owner.
## Apply (one attended operation)
1. Create one private mode-`0700` workspace with a cleanup trap. Keep the
pi-admin password and replacement LLDAP bind password in separate
mode-`0600` files or supply them only through the helper's hidden prompts.
2. From the pinned checkout, run exactly:
1. From the approved checkout, run exactly:
```text
bash sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh --apply
bash sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh --apply
```
The helper prompts for both passwords, authenticates to privacyIDEA, and
performs one `POST /resolver/lldap-coulomb`. It passes only protected file
paths to its child process, never prints values, and prints only a boolean
result. It must not be combined with `repair-realm-live.sh`,
`bootstrap-realm.sh`, `creds-rotate.sh`, or any full-bundle generator.
The helper securely prompts for pi-admin, replacement and predecessor
LLDAP passwords, and one approved MFA code; authenticates to privacyIDEA;
performs exactly one `POST /resolver/lldap-coulomb`; proves replacement
resolver lookup, privacyIDEA MFA, replacement LLDAP authentication, and
predecessor denial; rechecks readiness/health; securely cleans up; and
emits one sanitized PASS/FAIL receipt. It must not be combined with
`repair-realm-live.sh`, `bootstrap-realm.sh`, `creds-rotate.sh`, or any
full-bundle generator.
3. Stop immediately on any non-success response, timeout, unexpected endpoint,
or output that is not the documented boolean result. Do not restore the
exposed bundle or predecessor credential.
or failed cleanup. Do not restore the exposed bundle or predecessor
credential.
## Postflight and predecessor denial

View file

@ -0,0 +1,198 @@
#!/usr/bin/env bash
# reconcile-lldap-resolver-live.sh — one-command attended resolver cutover.
#
# Performs the narrow privacyIDEA lldap-coulomb update and its safe proof set:
# replacement LLDAP authentication, resolver lookup, one privacyIDEA MFA check,
# predecessor LLDAP denial, health/readiness, and cleanup. It never reads a
# Kubernetes Secret and never prints a credential, token, response body, or
# manifest.
#
# Usage:
# ./reconcile-lldap-resolver-live.sh --apply
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}"
LLDAP_AUTH_URL="${LLDAP_AUTH_URL:-https://lldap.coulomb.social/auth/simple/login}"
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}"
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}"
tmp="$(mktemp -d)"
chmod 700 "$tmp"
cleanup() {
for file in pi-admin lldap-new lldap-old otp; do
if [[ -f "$tmp/$file" ]]; then
shred -u "$tmp/$file" 2>/dev/null || rm -f "$tmp/$file"
fi
done
rmdir "$tmp" 2>/dev/null || true
}
cleanup_ok=0
trap cleanup EXIT INT TERM
prompt_secret() {
local label="$1" target="$2"
printf '%s: ' "$label" >&2
IFS= read -r -s value
printf '\n' >&2
if [[ -z "$value" ]]; then
echo "ERROR: empty protected input." >&2
exit 2
fi
printf '%s' "$value" > "$target"
unset value
chmod 600 "$target"
}
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"
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'
import json
import subprocess
import sys
import urllib.error
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,
) = sys.argv[1:]
def secret(path: str) -> str:
value = Path(path).read_text(encoding="utf-8")
if not value:
raise RuntimeError("empty protected input")
return value
def request(url: str, payload: dict | None = None, token: str | None = None) -> tuple[int, dict | 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
def check_k8s_ready() -> None:
workloads = (("sso", "lldap"), ("mfa", "privacyidea"), ("sso", "keycape"),
("sso", "authelia"), ("sso", "identity-provisioner"))
for namespace, name in workloads:
proc = subprocess.run(
["kubectl", "get", "deployment", name, "-n", namespace,
"-o", "jsonpath={.status.readyReplicas}/{.status.replicas}"],
capture_output=True, text=True, timeout=20,
)
if proc.returncode != 0 or proc.stdout.strip() != "1/1":
raise RuntimeError(f"readiness failed: {namespace}/{name}")
def check_health() -> None:
# privacyIDEA intentionally exposes no unauthenticated HTTP health route;
# /token/ is documented by the deployment as a safe availability probe.
for label, url, accepted in (
("privacyidea", pi_url + "/token/", {200, 401, 403}),
("keycape", discovery_url, set(range(200, 400))),
):
status, _ = request(url)
if status not in accepted:
raise RuntimeError(f"health failed: {label}")
def lldap_login(password: str) -> tuple[int, bool]:
status, body = request(lldap_auth_url, {"username": "admin", "password": password})
return status, status == 200 and isinstance(body, dict) and bool(body.get("token"))
try:
check_k8s_ready()
check_health()
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")
resolver_body = {
"type": "ldapresolver", "LDAPURI": lldap_url, "BINDDN": bind_dn,
"BINDPW": secret(new_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,
}
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")
status, result = request(pi_url + f"/user/?realm={mfa_realm}&pagesize=1", token=pi_token)
users = (result or {}).get("result", {}).get("value", {}).get("users", [])
if status != 200 or not users:
raise RuntimeError("replacement resolver lookup failed")
status, result = request(
pi_url + "/validate/check",
{"user": mfa_user, "realm": mfa_realm, "pass": secret(otp_path)},
pi_token,
)
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")
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
rc=0
else
rc=$?
fi
cleanup
trap - EXIT INT TERM
if [[ "$rc" -ne 0 ]]; then
echo "NK-WP-0033 receipt FAIL: proof checks did not pass; 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"

View file

@ -117,11 +117,13 @@ 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; the exact attended runbook is pinned in
NetKingdom now provides the supported one-command attended operation in
`sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh`. It is explicitly
gated by `--apply`, requires an interactive terminal, uses protected 0600
files, updates only `lldap-coulomb`, proves replacement resolver lookup and
privacyIDEA MFA, proves predecessor LLDAP denial with explicit 401/403 status,
rechecks readiness/health, and emits one sanitized PASS/FAIL receipt after
cleanup. It has not been run; the exact attended runbook is pinned in
`docs/keycape-exposure-resolver-reconciliation.md`.
Do not use `sso-mfa/bootstrap/creds-rotate.sh` through an agent as currently