diff --git a/docs/attended-procedure-inventory.md b/docs/attended-procedure-inventory.md index 8bb82b1..c801793 100644 --- a/docs/attended-procedure-inventory.md +++ b/docs/attended-procedure-inventory.md @@ -26,12 +26,12 @@ runbook or a dedicated procedure. Missing runbooks are listed separately. | `lldap/manage-group-members.sh` | [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md) | unknown | | `postgresql/create-secrets.sh` | [README.md](../sso-mfa/k8s/postgresql/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | | `privacyidea/bootstrap-admin.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown | -| `privacyidea/bootstrap-realm.sh` | [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md) | unknown | +| `privacyidea/bootstrap-realm.sh` | [privacyIDEA repair contract](../sso-mfa/k8s/privacyidea/README.md#configuration-preserving-realm-repair) | source repaired: literal private files, preview/explicit apply, configuration preservation; live acceptance pending | | `privacyidea/check-user-mfa-state.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | source repaired: explicit admitted private JWT, scoped read-only counts; live diagnostic delivery not admitted by this repair | | `privacyidea/create-secrets.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | | `privacyidea/enckey-bootstrap.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown | | `privacyidea/reconcile-lldap-resolver-live.sh` | [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md) | attempted by Bernd Worsch 2026-08-27; no completed PASS receipt | -| `privacyidea/repair-realm-live.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md), [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md), [verify-t06.md](../docs/verify-t06.md) | unknown | +| `privacyidea/repair-realm-live.sh` | [privacyIDEA repair contract](../sso-mfa/k8s/privacyidea/README.md#configuration-preserving-realm-repair), [verify-t06.md](../docs/verify-t06.md) | source repaired: attended in-memory input and propagated functional proof; live acceptance pending | | `privacyidea/update-lldap-resolver-live.sh` | None found | unknown | | `user-engine/verify-operability.sh` | None found | unknown | | `verify-identity-cutover-dependencies.sh` | None found | unknown | diff --git a/docs/keycape-exposure-resolver-reconciliation.md b/docs/keycape-exposure-resolver-reconciliation.md index c64b5b3..1c063b0 100644 --- a/docs/keycape-exposure-resolver-reconciliation.md +++ b/docs/keycape-exposure-resolver-reconciliation.md @@ -15,6 +15,18 @@ policies, tokens, KeyCape Secrets, or any other resolver. No password, hash, token, Secret payload, or manifest belongs in this document, State Hub, Git, chat, command arguments, or ordinary logs. +## Resolver update semantics — corrected 2026-10-13 + +Disposable native privacyIDEA 3.12.2 tests and its owner API implementation prove +that omitted resolver fields are preserved. The historical general statement +below that every POST replaces the whole object is superseded for this verified +version. Sending explicit defaults can still overwrite custom tuning. Historical +incident receipts and their target-version limitations remain unchanged. +The current general realm repair sends only missing tuning fields on an existing +resolver and never rotates its bind credential or replays MFA policies. The +incident-specific attended cutover remains a separate approved procedure; do not +substitute or combine the two. + ## Authority and pinning gate The operator must record a private approval receipt containing, at minimum: diff --git a/docs/smooth-bootstrap-guide.md b/docs/smooth-bootstrap-guide.md index cac9810..e2ceb95 100644 --- a/docs/smooth-bootstrap-guide.md +++ b/docs/smooth-bootstrap-guide.md @@ -65,8 +65,8 @@ Each step has: commands/scripts, evidence required, blocked conditions, links to ## Step 2: PrivacyIDEA Bootstrap + Realm -- Deploy/repair realm, LLDAP resolver, self-service policies. -- Use repair script (sso-mfa/k8s/privacyidea/repair-realm-live.sh) or console runbook "privacyIDEA realm repair". +- Repair missing realm/resolver configuration; preserve current owner-managed self-service/MFA policies. +- Use `sso-mfa/k8s/privacyidea/repair-realm-live.sh` or console action "Repair privacyIDEA realm and resolver". See the [configuration-preserving contract](../sso-mfa/k8s/privacyidea/README.md#configuration-preserving-realm-repair); missing onboarding policies use their existing owner procedure. - Enroll platform-root TOTP in repaired realm (pi-admin for setup). - Evidence: related to t02 validate (realm healthy). - Validate: `make security-bootstrap-validate-t02` (covers audit/recovery gates incl. this). diff --git a/docs/verify-t06.md b/docs/verify-t06.md index 623f3be..90fea18 100644 --- a/docs/verify-t06.md +++ b/docs/verify-t06.md @@ -9,8 +9,9 @@ a fresh TOTP/HOTP code including its token PIN if required. It never reads a bootstrap bundle or Kubernetes Secret. No password, OTP or token belongs in command arguments, logs or the receipt. Do not enable shell tracing. -`--pi-url`, `--realm`, and `--resolver` select a different approved target. -The CLI requires HTTPS. The old positional secrets-directory argument is +`--realm` and `--resolver` select an approved lookup. `--pi-url` is restricted +to the canonical provider or a literal loopback target; the CLI requires HTTPS. +The shared transport verifies system trust and refuses redirects and ambient proxies. The old positional secrets-directory argument is removed; `repair-realm-live.sh` now calls this interface and propagates failure. Success requires all of these properties: diff --git a/sso-mfa/WORKPLAN.md b/sso-mfa/WORKPLAN.md index 46e032e..f9b971f 100644 --- a/sso-mfa/WORKPLAN.md +++ b/sso-mfa/WORKPLAN.md @@ -96,11 +96,11 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co ## T06 — Realm config & MFA flow (KeyCape → privacyIDEA) ### Deliverables -- [x] `k8s/privacyidea/bootstrap-realm.sh` — creates LLDAP resolver, "netkingdom" realm, enrollment + passthru policies +- [x] `k8s/privacyidea/bootstrap-realm.sh` — current source repairs missing `coulomb` realm/LLDAP configuration; the historical enrollment/passthrough replay is withdrawn. See the privacyIDEA README for preview/apply and owner-managed onboarding policy. - [x] `k8s/verify-t06.sh` — verifies realm, resolver, KeyCape→PI token, connectivity ### In Progress (this session) -- [ ] Run `bootstrap-realm.sh` on live cluster (requires T04 applied) +- [ ] Run the reviewed configuration-preserving realm repair and functional proof (requires T04 and current owner admission; source tests are not live acceptance). - [ ] Establish RPF-WP-0040 scoped factor delivery, then first-bootstrap `keycape/create-secrets.sh` only if config is absent (old admin-token helpers retired). - [ ] Restart KeyCape with updated keycape-config - [ ] Enroll a TOTP token for pi-admin via pink-account.coulomb.social diff --git a/sso-mfa/k8s/privacyidea/README.md b/sso-mfa/k8s/privacyidea/README.md index 8197823..3c97189 100644 --- a/sso-mfa/k8s/privacyidea/README.md +++ b/sso-mfa/k8s/privacyidea/README.md @@ -251,3 +251,63 @@ not prove an MFA login. No token serial, metadata, provider error body or creden is emitted. Separate HTTP observations are not an atomic provider snapshot. Actual login remains `verify-t06.sh`/the supported user journey; pending enrollment must be confirmed before it counts as an active confirmed factor. + +## Configuration-preserving realm repair + +The attended entrypoint remains: + +```bash +bash sso-mfa/k8s/privacyidea/repair-realm-live.sh +``` + +It prompts for pi-admin in the terminal, reads current configuration, and asks +for an LLDAP bind password only when the named resolver is missing. Passwords +stay in process memory: no shell evaluation, environment-variable transport, +password arguments or temporary credential files. `MFA_USER` selects the user +for the final known-user lookup and fresh token-backed MFA proof. A failed proof +returns nonzero even if configuration writes already succeeded. + +For an owner-reviewed bootstrap using existing private generated files: + +```bash +bash sso-mfa/k8s/privacyidea/bootstrap-realm.sh /absolute/private/root +bash sso-mfa/k8s/privacyidea/bootstrap-realm.sh /absolute/private/root --apply +``` + +The first command authenticates and previews configuration actions; the second +executes the reviewed repair. `privacyidea/secrets.env` and `lldap/secrets.env` +are parsed as literal `NAME=value` records, never shell code. Root and component +directories must be owned/private (0700); files must be owned/private regular +unlinked files (0600). Shell-escaped `%q` files are not the input format. The old +third positional bind-password argument is rejected. An optional second +positional URL, or `PI_URL`, selects only the canonical verified HTTPS provider +or an explicit literal loopback fixture/approved forward. Proxy and redirect +credential forwarding are refused by shared `pi_api.py`. + +The supported repair creates an absent `lldap-coulomb` LDAP resolver and absent +`coulomb` realm. On an existing resolver it fills only missing TIMEOUT, +CACHE_TIMEOUT and SIZELIMIT, preserving valid custom values, the bind credential +and other fields. It refuses a different resolver type/backend or an existing +realm missing the expected binding; those need owner-reviewed migration. Existing +realm bindings/priorities and defaults survive. It sets `coulomb` as the initial +default only when the initial realm inventory is empty. + +No enrollment, deletion, passthrough or other policy is written. The deployed +onboarding guard, possession confirmation, pending-only cancellation and audited +recovery remain with their established KeyCape/NetKingdom owner procedures. +Replaying the old bootstrap's `enrollTOTP, delete, disable` or passthrough policy +would undo that hardening. A fresh instance still needs those owner-managed +onboarding components; a configuration receipt is not full IAM readiness. + +The provider has no compare-and-swap operation for these writes. Use an exclusive +owner maintenance window. The helper checks for drift before each write and +reads back afterward; that does not make the sequence atomic. On failure it +reports unverified/possible partial changes and stops, with no automatic retry, +policy rewrite, deletion or rollback. Reconcile the retained state before retry. + +Native disposable privacyIDEA 3.12.2 verifies that resolver POST preserves omitted +fields, including the bind password. This corrects the older runbook's general +replacement claim; it does not reclassify historical deployment evidence. +Reference: [provider resolver API](https://privacyidea.readthedocs.io/en/v3.12.2/modules/api/resolver.html). +Provider/package checks do not prove live LDAP reachability, current custody, +installed hooks, user enrollment or a complete KeyCape browser sign-in. diff --git a/sso-mfa/k8s/privacyidea/bootstrap-realm.sh b/sso-mfa/k8s/privacyidea/bootstrap-realm.sh index 14589a9..6466937 100755 --- a/sso-mfa/k8s/privacyidea/bootstrap-realm.sh +++ b/sso-mfa/k8s/privacyidea/bootstrap-realm.sh @@ -1,328 +1,5 @@ #!/usr/bin/env bash -# bootstrap-realm.sh — configure the "coulomb" realm in privacyIDEA -# -# Run AFTER bootstrap-admin.sh (pi-admin must exist and have MFA enrolled). -# -# What it does: -# 1. Authenticates as pi-admin to get a short-lived JWT. -# 2. Creates the LDAP resolver "lldap-coulomb" pointing to the in-cluster LLDAP. -# 3. Creates (or updates) the "coulomb" realm using that resolver. -# 4. Creates a self-enrollment policy: any authenticated user may enroll TOTP. -# 5. Prints a checklist of manual steps to complete via the WebUI. -# -# This script is idempotent: re-running it will update existing objects. -# -# Usage: -# ./bootstrap-realm.sh [secrets-dir] [pi-url] [lldap-bind-pass] -# -# default: ../../bootstrap/secrets -# default: https://pink.coulomb.social -# default: read from secrets-dir/lldap/secrets.env -# -# The script exits 0 if all steps succeed, non-zero otherwise. -# Partial failures are reported but do not abort subsequent steps. - +# Explicit private input; preview by default. Add --apply for the reviewed change. +set +x 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}" -PI_ENV="$SECRETS_DIR/privacyidea/secrets.env" -LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env" - -RESOLVER_NAME="lldap-coulomb" -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 - -ok() { echo " [OK] $1"; PASS_COUNT=$((PASS_COUNT + 1)); } -fail() { echo " [FAIL] $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); } -info() { echo " [INFO] $1"; } - -# ── Validate secrets ────────────────────────────────────────────────────────── -for f in "$PI_ENV" "$LLDAP_ENV"; do - if [[ ! -f "$f" ]]; then - echo "ERROR: $f not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2 - exit 1 - fi -done - -read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } - -PI_ADMIN_PASS=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD) -LLDAP_BIND_PW="${3:-$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS)}" - -if [[ -z "$PI_ADMIN_PASS" ]]; then - echo "ERROR: PI_ADMIN_PASSWORD not found in $PI_ENV" >&2 - exit 1 -fi -if [[ -z "$LLDAP_BIND_PW" ]]; then - echo "ERROR: LLDAP_LDAP_USER_PASS not found in $LLDAP_ENV" >&2 - exit 1 -fi - -# ── Authenticate ────────────────────────────────────────────────────────────── -echo "" -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"]})) -' | python3 "$PI_HELPER" POST "$PI_URL/auth"); then - echo "ERROR: privacyIDEA authentication request failed." >&2 - exit 1 -fi - -PI_TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c \ - "import sys,json; print(json.load(sys.stdin)['result']['value']['token'])" 2>/dev/null || echo "") - -if [[ -z "$PI_TOKEN" ]]; then - echo "ERROR: Authentication failed — check pi-admin credentials and MFA enrollment." >&2 - exit 1 -fi -info "Authenticated as pi-admin (token obtained)" - -pi_api() { - 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() { - local step="$1"; local resp="$2" - if [[ "$resp" == "CURL_FAILED" ]]; then - fail "$step — curl request failed" - return 1 - fi - local status - status=$(echo "$resp" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('status',''))" \ - 2>/dev/null || echo "") - if [[ "$status" == "True" || "$status" == "true" ]]; then - ok "$step" - return 0 - else - local err - err=$(echo "$resp" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('error',{}).get('message','unknown'))" \ - 2>/dev/null || echo "unknown") - fail "$step — $err" - return 1 - fi -} - -# ── 1. Create LDAP resolver ─────────────────────────────────────────────────── -echo "" -echo "Step 1: Creating LDAP resolver '$RESOLVER_NAME' ..." - -# USERINFO maps privacyIDEA fields to LLDAP/LDAP attributes. -# LLDAP uses standard inetOrgPerson attributes. -USERINFO='{"username": "uid", "phone": "telephoneNumber", "mobile": "mobile", "email": "mail", "surname": "sn", "givenname": "givenName"}' - -RESOLVER_BODY=$(LLDAP_BIND_PW="$LLDAP_BIND_PW" python3 -c " -import json, os -body = { - 'type': 'ldapresolver', - 'LDAPURI': '$(echo "$LLDAP_URL" | sed "s/'/'\\''/g")', - 'BINDDN': '$(echo "$LLDAP_BIND_DN" | sed "s/'/'\\''/g")', - 'BINDPW': os.environ['LLDAP_BIND_PW'], - 'LDAPBASE': '$LLDAP_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, - 'TIMEOUT': int('$LDAP_TIMEOUT'), - 'CACHE_TIMEOUT': int('$LDAP_CACHE_TIMEOUT'), - 'SIZELIMIT': int('$LDAP_SIZELIMIT') -} -print(json.dumps(body)) -") - -RESP=$(pi_api POST "/resolver/$RESOLVER_NAME" "$RESOLVER_BODY") -check_result "LDAP resolver '$RESOLVER_NAME' created/updated" "$RESP" || true - -# ── 2. Test resolver ────────────────────────────────────────────────────────── -echo "" -echo "Step 2: Testing resolver '$RESOLVER_NAME' ..." -TEST_RESP=$(pi_api GET "/resolver/$RESOLVER_NAME") -if [[ "$TEST_RESP" != "CURL_FAILED" ]]; then - RESOLVER_TYPE=$(echo "$TEST_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); r=d.get('result',{}).get('value',{}); print(list(r.values())[0].get('type','') if r else '')" \ - 2>/dev/null || echo "") - if [[ "$RESOLVER_TYPE" == "ldapresolver" ]]; then - ok "Resolver '$RESOLVER_NAME' exists and is type ldapresolver" - else - fail "Resolver '$RESOLVER_NAME' type unexpected: '$RESOLVER_TYPE'" - fi -else - fail "Could not retrieve resolver '$RESOLVER_NAME'" -fi - -# ── 3. Create realm ─────────────────────────────────────────────────────────── -echo "" -echo "Step 3: Creating realm '$REALM_NAME' ..." - -# privacyIDEA realm creation: POST /realm/ with resolvers list -REALM_BODY=$(python3 -c " -import json -body = { - 'resolvers': ['$RESOLVER_NAME'], - 'priority.$RESOLVER_NAME': 1 -} -print(json.dumps(body)) -") - -RESP=$(pi_api POST "/realm/$REALM_NAME" "$REALM_BODY") -check_result "Realm '$REALM_NAME' created/updated" "$RESP" || true - -# ── 4. Verify realm ─────────────────────────────────────────────────────────── -echo "" -echo "Step 4: Verifying realm '$REALM_NAME' ..." -REALM_RESP=$(pi_api GET "/realm/") -if [[ "$REALM_RESP" != "CURL_FAILED" ]]; then - REALM_EXISTS=$(echo "$REALM_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); realms=d.get('result',{}).get('value',{}); print('yes' if '$REALM_NAME' in realms else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$REALM_EXISTS" == "yes" ]]; then - ok "Realm '$REALM_NAME' confirmed in realm list" - else - fail "Realm '$REALM_NAME' not found in realm list after creation" - fi -else - fail "Could not retrieve realm list" -fi - -# ── 5. Set default realm ────────────────────────────────────────────────────── -echo "" -echo "Step 5: Setting '$REALM_NAME' as the default realm ..." -RESP=$(pi_api POST "/defaultrealm/$REALM_NAME") -check_result "Default realm set to '$REALM_NAME'" "$RESP" || true - -# ── 6. Create self-enrollment policy ───────────────────────────────────────── -echo "" -echo "Step 6: Creating self-enrollment policy ..." -# Allows users in the coulomb realm to self-enroll TOTP tokens via the self-service portal. -# Scope must be 'user' (not 'enrollment') — self-service actions live in the user scope. -# The WebUI self-service portal is at pink-account.coulomb.social. -ENROLL_POLICY=$(python3 -c " -import json -body = { - 'scope': 'user', - 'action': 'enrollTOTP, delete, disable', - 'realm': '$REALM_NAME', - 'user': '*', - 'client': '', - 'adminrealm': '', - 'priority': 1, - 'active': True -} -print(json.dumps(body)) -") - -RESP=$(pi_api POST "/policy/totp-self-enrollment" "$ENROLL_POLICY") -check_result "Policy 'totp-self-enrollment' created" "$RESP" || true - -# ── 7. Create human-readable authenticator labels ───────────────────────────── -echo "" -echo "Step 7: Creating authenticator label policy ..." -# Google Authenticator and compatible wallets display the issuer and account -# label embedded in the otpauth URI. Keep the token serial internal and make -# the user/realm visible so operators cannot confuse privileged identities. -TOKEN_LABEL_POLICY=$(python3 -c " -import json -body = { - 'scope': 'enrollment', - 'action': 'tokenissuer=Coulomb, tokenlabel={user}@{realm}', - 'realm': '$REALM_NAME', - 'user': '*', - 'client': '', - 'adminrealm': '', - 'priority': 1, - 'active': True -} -print(json.dumps(body)) -") - -RESP=$(pi_api POST "/policy/coulomb-friendly-token-labels" "$TOKEN_LABEL_POLICY") -check_result "Policy 'coulomb-friendly-token-labels' created" "$RESP" || true - -# ── 8. Create authentication policy (fail open for token-less users) ────────── -echo "" -echo "Step 8: Creating authentication policy ..." -# passthru: users without an enrolled token are allowed through (password only). -# This enables a phased MFA rollout: enroll first, enforce later. -# To enforce MFA for all users, remove 'passthru' from the action list and -# add 'otppin=tokenpin' instead — but ONLY after all users have enrolled a token. -AUTH_POLICY=$(python3 -c " -import json -body = { - 'scope': 'authentication', - 'action': 'passthru', - 'realm': '$REALM_NAME', - 'user': '*', - 'client': '', - 'adminrealm': '', - 'priority': 1, - 'active': True -} -print(json.dumps(body)) -") - -RESP=$(pi_api POST "/policy/mfa-passthru-phase1" "$AUTH_POLICY") -check_result "Policy 'mfa-passthru-phase1' created (passthru for token-less users)" "$RESP" || true - -# ── Summary ─────────────────────────────────────────────────────────────────── -echo "" -echo "════════════════════════════════════════════════════════════" -echo " Realm bootstrap: PASS=$PASS_COUNT FAIL=$FAIL_COUNT" -echo "════════════════════════════════════════════════════════════" -echo "" - -if [[ "$FAIL_COUNT" -gt 0 ]]; then - echo "INCOMPLETE — resolve FAIL items above before proceeding." - echo "" -fi - -echo "Manual steps required after this script:" -echo "" -echo " 1. Verify the LDAP resolver resolves users:" -echo " WebUI → Config → Resolver → $RESOLVER_NAME → [test] → enter a username" -echo " A user should resolve from LLDAP. If not, check the LLDAP bind password." -echo "" -echo " 2. Log in to the self-service portal as a regular user:" -echo " https://pink-account.coulomb.social" -echo " Enroll a TOTP token (use Google Authenticator or a hardware key)." -echo "" -echo " 3. Test MFA via KeyCape:" -echo " Visit an application protected by kc.coulomb.social." -echo " After password auth (Authelia), you should see a TOTP challenge." -echo " Enter the OTP. If it passes, T06 is complete." -echo "" -echo " 4. Phase 2 — enforce MFA (after all users have enrolled):" -echo " WebUI → Config → Policies → mfa-passthru-phase1 → set active=False" -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 --user platform-root" - -if [[ "$FAIL_COUNT" -gt 0 ]]; then - exit 1 -fi -exit 0 +exec python3 "$(dirname -- "$0")/realm_bootstrap.py" "$@" diff --git a/sso-mfa/k8s/privacyidea/pi_api.py b/sso-mfa/k8s/privacyidea/pi_api.py index 63e325d..48a233a 100644 --- a/sso-mfa/k8s/privacyidea/pi_api.py +++ b/sso-mfa/k8s/privacyidea/pi_api.py @@ -1,56 +1,68 @@ -"""Shared JSON request transport. Credentials stay in memory or stdin, never argv.""" - +"""Captured JSON transport for protected callers; never run against live data to stdout.""" import json +import ssl import sys import urllib.error +import urllib.parse import urllib.request +from mfa_state import NoRedirect, endpoint, unique_object + +MAX_BODY = 1024 * 1024 + 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 + parsed = urllib.parse.urlsplit(url) + base = endpoint(urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))) + if parsed.fragment or not parsed.path.startswith('/') or any(ord(c) < 32 for c in url): + return 0, None + # Origin validated before creating a request containing protected values. + headers = {'Accept': 'application/json'} + data = None + if payload is not None: + data = json.dumps(payload).encode('utf-8') + if len(data) > MAX_BODY: return 0, None + headers['Content-Type'] = 'application/json' + if token: + if not isinstance(token, str) or len(token) > 16384 or any(ord(c) < 33 or ord(c) > 126 for c in token): + return 0, None + headers['Authorization'] = token + req = urllib.request.Request(base + parsed.path + ('?' + parsed.query if parsed.query else ''), + data=data, headers=headers, method=method or ('POST' if payload is not None else 'GET')) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), + urllib.request.HTTPSHandler(context=context)) + with opener.open(req, timeout=20) as response: + status, raw = response.status, response.read(MAX_BODY + 1) + if len(raw) > MAX_BODY: return status, None + return status, json.loads(raw, object_pairs_hook=unique_object) if raw else None except urllib.error.HTTPError as exc: exc.close() return exc.code, None - except (urllib.error.URLError, TimeoutError, OSError): + except Exception: + # Callers emit fixed phases, never exception strings or response bodies. 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") + try: + if len(sys.argv) != 3 or sys.argv[1] not in ('GET', 'POST'): + raise ValueError() + token = sys.stdin.readline(16386).removesuffix('\n') + body = sys.stdin.read(MAX_BODY + 1) + if len(body) > MAX_BODY: raise ValueError() + status, result = request(sys.argv[2], json.loads(body, object_pairs_hook=unique_object) if body else None, + token or None, method=sys.argv[1]) + if not 200 <= status < 300 or not isinstance(result, dict): raise ValueError() + # Private shell adapter: caller must capture this; it can contain a JWT/config. + print(json.dumps(result)) + return 0 + except Exception: + print('CURL_FAILED') return 1 - print(json.dumps(result)) - return 0 -if __name__ == "__main__": +if __name__ == '__main__': raise SystemExit(main()) diff --git a/sso-mfa/k8s/privacyidea/realm_bootstrap.py b/sso-mfa/k8s/privacyidea/realm_bootstrap.py new file mode 100644 index 0000000..059fa84 --- /dev/null +++ b/sso-mfa/k8s/privacyidea/realm_bootstrap.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Repair missing realm/resolver configuration while preserving established policy.""" +import argparse +import getpass +import importlib.util +import json +import os +import re +from pathlib import Path +import sys + +from mfa_state import QuietParser, endpoint +from pi_api import request + +RESOLVER = 'lldap-coulomb' +REALM = 'coulomb' +CORE = {'LDAPURI': 'ldap://lldap.sso.svc.cluster.local:3890', + 'LDAPBASE': 'dc=netkingdom,dc=local', 'BINDDN': 'uid=admin,ou=people,dc=netkingdom,dc=local'} +TUNING = {'TIMEOUT': '5', 'CACHE_TIMEOUT': '120', 'SIZELIMIT': '500'} + + +class Refused(Exception): + pass + + +def password(value): + if not isinstance(value, str) or not value or len(value.encode()) > 4096 or any(ord(c) < 32 for c in value): + raise Refused() + return value + + +def private_inputs(root): + path = Path(__file__).resolve().parents[2] / 'bootstrap/apply-service-secrets.py' + spec = importlib.util.spec_from_file_location('realm_custody', path) + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + with module.custody(root, ['privacyidea', 'lldap']) as dirs: + admin = password(module.read_env(dirs['privacyidea']).get('PI_ADMIN_PASSWORD')) + bind = password(module.read_env(dirs['lldap']).get('LLDAP_LDAP_USER_PASS')) + return admin, bind + + +class Client: + def __init__(self, url, admin_password): + self.url = endpoint(url) + self.token = None + auth = self.call('/auth', {'username': 'pi-admin', 'password': password(admin_password)}) + if not isinstance(auth, dict) or auth.get('role') != 'admin' or not isinstance(auth.get('token'), str) or not auth['token']: + raise Refused() + self.token = auth['token'] + + def call(self, path, payload=None): + status, body = request(self.url + path, payload, self.token) + result = body.get('result') if isinstance(body, dict) else None + if status != 200 or not isinstance(result, dict) or result.get('status') is not True: + raise Refused() + return result.get('value') + + def snapshot(self): + resolvers = self.call('/resolver/' + RESOLVER) + realms = self.call('/realm/') + if not isinstance(resolvers, dict) or set(resolvers) - {RESOLVER} or not isinstance(realms, dict): + raise Refused() + if any(not isinstance(v, dict) for v in realms.values()): raise Refused() + return {'resolver': resolvers.get(RESOLVER), 'realms': realms} + + +def plan(snapshot): + resolver, realms = snapshot['resolver'], snapshot['realms'] + updates = {} + if resolver is None: + updates = dict(CORE, **TUNING, type='ldapresolver', 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) + else: + if not isinstance(resolver, dict) or resolver.get('type') != 'ldapresolver' or not isinstance(resolver.get('data'), dict): + raise Refused() + data = resolver['data'] + if any(data.get(key) != value for key, value in CORE.items()): raise Refused() + for key, default in TUNING.items(): + value = data.get(key) + if value in (None, ''): updates[key] = default + elif type(value) not in (str, int) or not str(value).isascii() or not str(value).isdigit(): raise Refused() + if updates: updates['type'] = 'ldapresolver' + realm = realms.get(REALM) + if realm is not None: + bindings = realm.get('resolver') + if (not isinstance(bindings, list) or not any(isinstance(v, dict) and v.get('name') == RESOLVER for v in bindings)): + raise Refused() + return {'resolver_update': updates, 'create_resolver': resolver is None, + 'create_realm': realm is None, 'set_initial_default': not realms} + + +def repair(client, bind_input, *, apply=False): + initial = client.snapshot() + changes = plan(initial) + actions = [] + if changes['resolver_update']: actions.append('create-resolver' if changes['create_resolver'] else 'fill-missing-tuning') + if changes['create_realm']: actions.append('create-realm') + if changes['set_initial_default']: actions.append('set-first-realm-default') + if not apply: + return {'result': 'planned', 'actions': actions, 'configuration_writes': 0, 'policies_changed': False, 'mfa_verified': False} + bind = password(bind_input()) if changes['create_resolver'] else None + # Recheck the full observed target before each operation. API has no CAS; + # an exclusive owner window remains required, and ambiguous writes are not retried. + expected = initial + for action in actions: + if client.snapshot() != expected: raise Refused() + if action in ('create-resolver', 'fill-missing-tuning'): + payload = dict(changes['resolver_update']) + if action == 'create-resolver': payload['BINDPW'] = bind + value = client.call('/resolver/' + RESOLVER, payload) + if type(value) is not int or value < 1: raise Refused() + after = client.snapshot() + current = after['resolver'] + if not isinstance(current, dict) or current.get('type') != 'ldapresolver': raise Refused() + data = current.get('data', {}) + for key, desired in payload.items(): + if key in ('type', 'BINDPW'): continue + actual = data.get(key) + if isinstance(desired, bool): + if str(actual).lower() != str(desired).lower(): raise Refused() + elif str(actual) != str(desired): raise Refused() + if action == 'fill-missing-tuning': + old = expected['resolver']['data'] + if any(data.get(k) != v for k, v in old.items() if k not in payload): raise Refused() + if after['realms'] != expected['realms']: raise Refused() + elif action == 'create-realm': + value = client.call('/realm/' + REALM, {'resolvers': [RESOLVER], 'priority.'+RESOLVER: 1}) + if not isinstance(value, dict) or value.get('failed') != [] or value.get('added') != [RESOLVER]: raise Refused() + after = client.snapshot() + realm = after['realms'].get(REALM) + if not isinstance(realm, dict) or [r.get('name') for r in realm.get('resolver', [])] != [RESOLVER]: raise Refused() + if {k:v for k,v in after['realms'].items() if k != REALM} != expected['realms']: raise Refused() + if after['resolver'] != expected['resolver']: raise Refused() + else: + value = client.call('/defaultrealm/' + REALM, {}) + if type(value) is not int or value < 1: raise Refused() + after = client.snapshot() + anticipated = json.loads(json.dumps(expected)) + anticipated['realms'][REALM]['default'] = True + if after != anticipated: raise Refused() + expected = after + final = client.snapshot() + if final != expected or plan(final)['resolver_update'] or plan(final)['create_realm']: raise Refused() + return {'result': 'configured', 'actions': actions, 'configuration_writes': len(actions), + 'policies_changed': False, 'mfa_verified': False, 'onboarding_policy': 'owner-managed'} + + +def main(): + phase = 'input' + try: + parser = QuietParser(description=__doc__) + parser.add_argument('root', nargs='?') + parser.add_argument('url', nargs='?', default=os.environ.get('PI_URL', 'https://pink.coulomb.social')) + parser.add_argument('--pi-url') + parser.add_argument('--attended', action='store_true') + parser.add_argument('--apply', action='store_true') + parser.add_argument('--verify-user') + args = parser.parse_args() + base = endpoint(args.pi_url or args.url) + if args.verify_user and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', args.verify_user): raise Refused() + if args.attended: + if args.root is not None or not sys.stdin.isatty(): raise Refused() + admin = password(getpass.getpass('privacyIDEA pi-admin password: ')) + bind_input = lambda: getpass.getpass('LLDAP bind password for missing resolver: ') + else: + if args.root is None or args.verify_user: raise Refused() + admin, bind = private_inputs(args.root) + bind_input = lambda: bind + phase = 'configuration' + client = Client(base, admin) + report = repair(client, bind_input, apply=args.apply) + if args.verify_user and args.apply: + phase = 'functional-verification' + from verify_mfa import verify + proof = verify(base, args.verify_user, REALM, RESOLVER, admin, + lambda:getpass.getpass('Fresh MFA code (include token PIN if required): ')) + if proof['result'] != 'PASS': raise Refused() + report['mfa_verified'] = True + print(json.dumps(report, sort_keys=True)) + return 0 + except (Exception, KeyboardInterrupt): + print(json.dumps({'result':'unverified','phase':phase,'partial_changes_possible':phase!='input'}), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/sso-mfa/k8s/privacyidea/repair-realm-live.sh b/sso-mfa/k8s/privacyidea/repair-realm-live.sh index afee70d..5e0113a 100755 --- a/sso-mfa/k8s/privacyidea/repair-realm-live.sh +++ b/sso-mfa/k8s/privacyidea/repair-realm-live.sh @@ -1,45 +1,9 @@ #!/usr/bin/env bash -# repair-realm-live.sh - attended repair for privacyIDEA realm bootstrap state. -# -# This wrapper prompts for live passwords, writes them only to a private -# temporary directory, runs the idempotent realm bootstrap, and removes the -# temporary files on exit. - +# Attended configuration repair and functional proof; passwords remain in memory. +set +x set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -SSO_MFA_K8S_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) -PI_URL="${PI_URL:-https://pink.coulomb.social}" - -if [[ ! -t 0 ]]; then - echo "ERROR: repair-realm-live.sh needs an interactive terminal for password prompts." >&2 - exit 1 +if [[ $# -ne 0 ]]; then + printf '%s\n' 'ERROR: this attended entrypoint takes no positional credential inputs.' >&2 + exit 2 fi - -export PATH="/home/worsch/.local/bin:$PATH" -umask 077 - -tmp="$(mktemp -d)" -cleanup() { - rm -rf "$tmp" - unset PI_ADMIN_PASSWORD LLDAP_LDAP_USER_PASS -} -trap cleanup EXIT - -mkdir -p "$tmp/privacyidea" "$tmp/lldap" - -printf "privacyIDEA pi-admin password: " >&2 -read -rs PI_ADMIN_PASSWORD -printf "\n" >&2 -printf "LLDAP bind/admin password: " >&2 -read -rs LLDAP_LDAP_USER_PASS -printf "\n" >&2 - -printf "PI_ADMIN_PASSWORD=%q\n" "$PI_ADMIN_PASSWORD" > "$tmp/privacyidea/secrets.env" -printf "LLDAP_LDAP_USER_PASS=%q\n" "$LLDAP_LDAP_USER_PASS" > "$tmp/lldap/secrets.env" - -bash "$SCRIPT_DIR/bootstrap-realm.sh" "$tmp" "$PI_URL" - -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." +exec python3 "$(dirname -- "$0")/realm_bootstrap.py" --attended --apply --verify-user "${MFA_USER:-platform-root}" diff --git a/tests/realm_bootstrap_native_fixture.py b/tests/realm_bootstrap_native_fixture.py new file mode 100644 index 0000000..5bfbea1 --- /dev/null +++ b/tests/realm_bootstrap_native_fixture.py @@ -0,0 +1,84 @@ +"""Disposable privacyIDEA 3.12.2 HTTP and database proof, no production config.""" +import contextlib +import io +import json +import logging +import os +from pathlib import Path +import sys +import tempfile +import threading + + +def run(): + for name in list(os.environ): + if name.startswith('PRIVACYIDEA_'):del os.environ[name] + logging.disable(logging.CRITICAL) + sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'sso-mfa/k8s/privacyidea')) + import realm_bootstrap as r + from privacyidea.app import create_app + from privacyidea.models import db + from privacyidea.lib.auth import create_db_admin + from privacyidea.lib.policy import PolicyClass,set_policy + from privacyidea.lib.resolver import get_resolver_config,save_resolver + from privacyidea.lib.realm import set_realm,set_default_realm + from werkzeug.serving import make_server + with tempfile.TemporaryDirectory(prefix='realm-native-') as directory: + root=Path(directory);(root/'enckey').write_bytes(os.urandom(96)) + cfg=root/'fixture.cfg' + config=dict(SQLALCHEMY_DATABASE_URI='sqlite:///'+str(root/'db.sqlite'),SECRET_KEY='fixture-secret-key', + PI_PEPPER='fixture-pepper',PI_ENCFILE=str(root/'enckey'),PI_NO_RESPONSE_SIGN=True, + PI_AUDIT_NO_SIGN=True,PI_LOGFILE=str(root/'log'),PI_TRUSTED_JWT=[], + PI_AUDIT_SQL_URI='sqlite:///'+str(root/'audit.sqlite')) + cfg.write_text('\n'.join(k+'='+repr(v) for k,v in config.items())) + app=create_app(config_name='testing',config_file=str(cfg),silent=True) + with app.app_context(): + assert str(db.engine.url)==config['SQLALCHEMY_DATABASE_URI'];db.create_all() + from privacyidea.lib.audit import getAudit + from privacyidea.lib.auditmodules.sqlaudit import LogEntry + LogEntry.__table__.create(getAudit(app.config).engine,checkfirst=True) + create_db_admin('pi-admin',password='fixture-admin-password') + policies=PolicyClass().policies + server=make_server('127.0.0.1',0,app);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + try: + client=r.Client(f'http://127.0.0.1:{server.server_port}','fixture-admin-password') + preview=r.repair(client,lambda:None) + assert preview['configuration_writes']==0 + result=r.repair(client,lambda:'fixture-bind-password',apply=True) + assert result['actions']==['create-resolver','create-realm','set-first-realm-default'],result + with app.app_context(): + assert get_resolver_config(r.RESOLVER)['BINDPW']=='fixture-bind-password' + assert PolicyClass().policies==policies + set_policy(name='totp-self-enrollment',scope='user',action='enrollTOTP',realm='coulomb') + set_policy(name='keycape-pending-enrollment-cancel',scope='user',action='delete',realm='coulomb',conditions=[('token','rollout_state','equals','verify',True)]) + set_policy(name='mfa-passthru-phase1',scope='authentication',action='passthru',realm='coulomb',active=False) + policies=PolicyClass().policies + save_resolver({'resolver':r.RESOLVER,'type':'ldapresolver','TIMEOUT':'','SIZELIMIT':'777','TLS_VERIFY':True,'CACHE_TIMEOUT':'333'}) + set_realm('other',[]);set_default_realm('other') + before=client.snapshot() + result=r.repair(client,lambda:(_ for _ in ()).throw(AssertionError()),apply=True) + assert result['actions']==['fill-missing-tuning'] + after=client.snapshot() + assert after['realms']==before['realms'] + assert after['resolver']['data']['TIMEOUT']=='5' + for key,value in before['resolver']['data'].items(): + if key!='TIMEOUT':assert after['resolver']['data'][key]==value + with app.app_context(): + assert get_resolver_config(r.RESOLVER)['BINDPW']=='fixture-bind-password' + assert PolicyClass().policies==policies + assert r.repair(client,lambda:None,apply=True)['configuration_writes']==0 + return {'native_create_and_readback':True,'native_minimal_update_preserves_omitted_fields':True, + 'native_bind_password_preserved':True,'native_hardened_policies_preserved':True, + 'native_existing_default_preserved':True,'native_repeat_is_noop':True} + finally:server.shutdown();thread.join();server.server_close() + + +if __name__=='__main__': + try: + with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):result=run() + print(json.dumps(result,sort_keys=True)) + except Exception as exc: + import traceback + frame=traceback.extract_tb(exc.__traceback__)[-1] + print(json.dumps({'result':'failed','type':type(exc).__name__,'file':Path(frame.filename).name,'line':frame.lineno})) + raise SystemExit(1) diff --git a/tests/test_privacyidea_verification.py b/tests/test_privacyidea_verification.py index da377fa..fb5b11e 100644 --- a/tests/test_privacyidea_verification.py +++ b/tests/test_privacyidea_verification.py @@ -26,7 +26,7 @@ def result(value, **extra): @pytest.fixture def target(): state = { - "/auth": result({"token": "test-token"}), + "/auth": result({"token": "test-token", "role": "admin"}), "/realm/": result({"coulomb": {"resolver": [{"name": "lldap-coulomb"}]}}), "/resolver/lldap-coulomb": result( { @@ -180,7 +180,7 @@ def test_shell_entrypoint_requires_attendance_and_does_not_read_bundle(): @pytest.mark.parametrize("policy_ok", [True, False]) -def test_bootstrap_uses_shared_transport_and_reports_full_result( +def test_bootstrap_preserves_policy_and_existing_configuration( target, tmp_path, policy_ok ): url, state = target @@ -199,20 +199,61 @@ def test_bootstrap_uses_shared_transport_and_reports_full_result( ("lldap", "LLDAP_LDAP_USER_PASS=test-bind-password\n"), ]: directory = tmp_path / folder - directory.mkdir() + directory.mkdir(mode=0o700) (directory / "secrets.env").write_text(content) + (directory / "secrets.env").chmod(0o600) + state["/resolver/lldap-coulomb"]["result"]["value"]["lldap-coulomb"]["data"].update({ + "LDAPURI": "ldap://lldap.sso.svc.cluster.local:3890", "LDAPBASE": "dc=netkingdom,dc=local", + "BINDDN": "uid=admin,ou=people,dc=netkingdom,dc=local"}) proc = subprocess.run( - ["bash", str(HELPERS / "bootstrap-realm.sh"), str(tmp_path), url], + ["bash", str(HELPERS / "bootstrap-realm.sh"), str(tmp_path), url, "--apply"], capture_output=True, text=True, check=False, ) - assert proc.returncode == (0 if policy_ok else 1) - assert "Realm bootstrap: PASS=" in proc.stdout - assert "FAIL=0" in proc.stdout if policy_ok else "FAIL=1" in proc.stdout + assert proc.returncode == 0, proc.stderr + report = json.loads(proc.stdout) + assert report["configuration_writes"] == 0 and report["policies_changed"] is False + assert not any(path.startswith("/policy") for _, path, _, _ in state["requests"]) for secret in ["test-token", "test-password", "test-bind-password"]: assert secret not in proc.stdout + proc.stderr assert any( method == "GET" and path == "/resolver/lldap-coulomb" for method, path, _, _ in state["requests"] ) + + +def test_attended_repair_keeps_inputs_in_memory_and_propagates_mfa(target, monkeypatch): + import os + import pty + import selectors + import time + url, state = target + state['/resolver/lldap-coulomb']['result']['value']['lldap-coulomb']['data'].update({ + 'LDAPURI':'ldap://lldap.sso.svc.cluster.local:3890','LDAPBASE':'dc=netkingdom,dc=local', + 'BINDDN':'uid=admin,ou=people,dc=netkingdom,dc=local'}) + monkeypatch.setenv('PI_URL',url);monkeypatch.setenv('MFA_USER','test-user') + master,slave=pty.openpty() + proc=subprocess.Popen(['bash',str(HELPERS/'repair-realm-live.sh')],stdin=slave,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + os.close(slave);selector=selectors.DefaultSelector();selector.register(proc.stderr,selectors.EVENT_READ) + errors=b'';sent_password=False;sent_otp=False;deadline=time.monotonic()+30 + try: + while proc.poll() is None and time.monotonic() list[dict[str, str]]: return [ action( - "Repair privacyIDEA realm and self-service", - "Prompt for pi-admin and LLDAP bind passwords, recreate the coulomb realm, LLDAP resolver, self-enrollment policy, and passthrough bootstrap policy, then run T06 verification. The wrapper keeps passwords in a private temporary directory that is removed on exit.", + "Repair privacyIDEA realm and resolver", + "Prompt for pi-admin and, only for a missing resolver, its LLDAP bind password. Restore missing resolver/realm configuration and tuning while preserving credentials, existing realm bindings, defaults and owner-managed MFA policies; then run functional T06 verification. Passwords stay in memory. Conflicts require an owner-reviewed migration.", privacyidea_realm_command, ), action( diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index f109802..fc1307d 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -210,9 +210,11 @@ Remaining closure checklist (source review, not proof of current live use): Targeted source findings also include `manage-group-members.sh`, `create-user.sh`, `break-glass.sh`, `dry-run-nonroot-user.sh` and `verify-t07.sh`; their caller and lifecycle review remains incomplete. -- `privacyidea/repair-realm-live.sh` writes shell-escaped credentials for - `bootstrap-realm.sh`, which still sources them. Reconcile both ends together; - a private temporary directory alone does not establish the transport contract. +- `privacyidea/repair-realm-live.sh` and `bootstrap-realm.sh` are repaired below: + attended in-memory or literal private-file input, minimal configuration repair, + and preservation of owner-managed policies. Current live acceptance remains + owner-scoped; incident-specific resolver cutover/rotation callers still need + the complete inventory and configuration-preservation review. - Reconcile the complete `docs/attended-procedure-inventory.md` against current scripts and Make/bootstrap/root-output callers. The selected findings above are not an exhaustive closure audit. Retain historical revocation and @@ -268,6 +270,25 @@ Source/native disposable verification and limits are recorded in Custodian KeyCape-only RPF-WP-0040 delivery is not treated as authorization for this helper. LDAP/realm callers, full inventory and T03 native recovery remain open. +2026-10-13 realm/bootstrap source repair: replaced shell-sourced/%q credential +transport with literal private files or attended in-memory prompts. Preview is +read-only after authentication; `--apply` executes missing-object/tuning repair. +Existing bind credentials, valid custom tuning, realm bindings/priorities and +defaults are preserved; target/profile drift refuses before mutation. Existing +policies are never replayed. The unsafe broad self-service/passthrough bootstrap +writes are withdrawn in favor of the established onboarding-policy owner flow. +The attended wrapper retains functional lookup/fresh-MFA verification and fails +on partial/unknown outcomes without retry. Shared `pi_api.py` now pins allowed +origins, verifies TLS, refuses redirects/proxies, bounds responses and sanitizes +errors. The console and guides reflect these boundaries. +Actual privacyIDEA 3.12.2 proves minimal resolver POST preserves omitted fields; +this corrects the historical general replacement claim without rewriting its +incident receipts. Disposable and broader test outcomes are in Custodian +`docs/evidence/2026-10-13-netkingdom-security/realm-bootstrap-repair.json`. +No live credential, resolver, realm, policy or token was touched. LDAP callers, +incident-specific rotation/cutover review, full inventory and native recovery +acceptance remain open; T02 stays progress and T03 wait. + ## Verify owner-approved recovery and current native callers ```task