security: preserve realm configuration and remove shell credential evaluation
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: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
tegwick 2026-10-13 22:47:24 +02:00
parent 96feef46ab
commit 30f0d27840
16 changed files with 700 additions and 430 deletions

View file

@ -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 |

View file

@ -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:

View file

@ -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).

View file

@ -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:

View file

@ -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

View file

@ -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.

View file

@ -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]
#
# <secrets-dir> default: ../../bootstrap/secrets
# <pi-url> default: https://pink.coulomb.social
# <lldap-bind-pass> 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/<name> 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" "$@"

View file

@ -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())

View file

@ -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())

View file

@ -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}"

View file

@ -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)

View file

@ -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()<deadline:
for key,_ in selector.select(0.1):
chunk=os.read(key.fileobj.fileno(),4096)
if not chunk:selector.unregister(key.fileobj);continue
errors+=chunk
if b'pi-admin password:' in errors and not sent_password:
os.write(master,b'test-password\n');sent_password=True
if b'Fresh MFA code' in errors and not sent_otp:
os.write(master,b'test-otp\n');sent_otp=True
assert proc.poll() is not None,'attended helper did not complete'
stdout,stderr=proc.communicate();errors+=stderr
assert proc.returncode==0 and sent_password and sent_otp
report=json.loads(stdout);assert report['mfa_verified'] is True and report['configuration_writes']==0
assert b'test-password' not in stdout+errors and b'test-otp' not in stdout+errors
assert b'LLDAP bind password' not in errors
assert not any(path.startswith('/policy') for _,path,_,_ in state['requests'])
finally:
if proc.poll() is None:proc.kill();proc.wait()
selector.close();os.close(master)

View file

@ -0,0 +1,207 @@
"""Configuration-preserving realm repair, private input and real HTTP boundaries."""
import copy
import importlib
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from urllib.parse import urlsplit
import pytest
ROOT=Path(__file__).resolve().parents[1]
HELPERS=ROOT/'sso-mfa/k8s/privacyidea'
sys.path.insert(0,str(HELPERS))
r=importlib.import_module('realm_bootstrap')
p=importlib.import_module('pi_api')
def snapshot():
return {'resolver':{'type':'ldapresolver','data':dict(r.CORE,**{**r.TUNING,'SIZELIMIT':'700'},BINDPW='__CENSORED__',TLS_VERIFY='True')},
'realms':{'coulomb':{'resolver':[{'name':r.RESOLVER,'priority':5}], 'default':False},
'other':{'resolver':[], 'default':True}}}
class Fake:
def __init__(self,state): self.state=copy.deepcopy(state);self.calls=[];self.fail=None;self.drift=None;self.reads=0
def snapshot(self):
self.reads+=1
if self.drift and self.reads==2:self.state['realms']['other']['default']=False
return copy.deepcopy(self.state)
def call(self,path,payload):
self.calls.append((path,copy.deepcopy(payload)))
if self.fail==path:raise r.Refused()
if path=='/resolver/'+r.RESOLVER:
if self.state['resolver'] is None:self.state['resolver']={'type':'ldapresolver','data':{}}
self.state['resolver']['data'].update({k:('__CENSORED__' if k=='BINDPW' else v) for k,v in payload.items() if k!='type'})
return 1
if path=='/realm/coulomb':
self.state['realms']['coulomb']={'resolver':[{'name':r.RESOLVER,'priority':1}],'default':False}
return {'added':[r.RESOLVER],'failed':[]}
if path=='/defaultrealm/coulomb':self.state['realms']['coulomb']['default']=True;return 1
raise AssertionError('unexpected write')
def test_existing_configuration_is_noop_without_reading_bind_password():
client=Fake(snapshot());before=copy.deepcopy(client.state)
def forbidden():raise AssertionError('existing bind must not be requested')
report=r.repair(client,forbidden,apply=True)
assert report['configuration_writes']==0 and client.state==before and not client.calls
def test_missing_tuning_is_minimal_and_preserves_nondefault_values():
client=Fake(snapshot());del client.state['resolver']['data']['TIMEOUT'];before=copy.deepcopy(client.state)
report=r.repair(client,lambda:None,apply=True)
assert report['configuration_writes']==1
assert client.calls==[('/resolver/'+r.RESOLVER,{'TIMEOUT':'5','type':'ldapresolver'})]
before['resolver']['data']['TIMEOUT']='5';assert client.state==before
def test_missing_objects_are_created_and_other_default_is_preserved():
state=snapshot();state['resolver']=None;del state['realms']['coulomb'];client=Fake(state)
result=r.repair(client,lambda:'fixture-bind',apply=True)
assert result['actions']==['create-resolver','create-realm']
assert client.state['realms']['other']==state['realms']['other']
assert all(not route.startswith('/policy') for route,_ in client.calls)
def test_first_realm_gets_explicit_initial_default():
client=Fake({'resolver':None,'realms':{}})
result=r.repair(client,lambda:'fixture-bind',apply=True)
assert result['configuration_writes']==3 and client.state['realms']['coulomb']['default'] is True
def test_preview_has_no_configuration_writes_or_bind_prompt():
client=Fake({'resolver':None,'realms':{}})
report=r.repair(client,lambda:(_ for _ in ()).throw(AssertionError()),apply=False)
assert report['result']=='planned' and not client.calls
@pytest.mark.parametrize('field,value',[('type','other'),('LDAPURI','ldap://other'),('TIMEOUT','invalid')])
def test_conflicting_profile_refused_before_any_write(field,value):
state=snapshot()
if field=='type':state['resolver']['type']=value
else:state['resolver']['data'][field]=value
client=Fake(state)
with pytest.raises(r.Refused):r.repair(client,lambda:'fixture-bind',apply=True)
assert not client.calls
def test_existing_wrong_realm_binding_refused_before_resolver_write():
state=snapshot();state['realms']['coulomb']['resolver']=[];del state['resolver']['data']['TIMEOUT'];client=Fake(state)
with pytest.raises(r.Refused):r.repair(client,lambda:None,apply=True)
assert not client.calls
def test_preflight_drift_refused():
client=Fake(snapshot());del client.state['resolver']['data']['TIMEOUT'];client.drift=True
with pytest.raises(r.Refused):r.repair(client,lambda:None,apply=True)
assert not client.calls
def test_partial_failure_stops_without_policy_or_default_writes():
client=Fake({'resolver':None,'realms':{}});client.fail='/realm/coulomb'
with pytest.raises(r.Refused):r.repair(client,lambda:'fixture-bind',apply=True)
assert len(client.calls)==2 and client.state['resolver'] is not None
@pytest.fixture
def server():
state={'requests':[],'response':{'result':{'status':True,'value':{}}},'status':200}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*_):pass
def do_GET(self):self.respond()
def do_POST(self):self.respond()
def respond(self):
body=self.rfile.read(int(self.headers.get('Content-Length',0)))
state['requests'].append((self.path,dict(self.headers),body))
self.send_response(state['status']);self.send_header('Location','/leak');self.end_headers()
self.wfile.write(state.get('raw',json.dumps(state['response']).encode()))
s=ThreadingHTTPServer(('127.0.0.1',0),Handler);t=threading.Thread(target=s.serve_forever,daemon=True);t.start()
try:yield f'http://127.0.0.1:{s.server_port}',state
finally:s.shutdown();s.server_close();t.join()
@pytest.mark.parametrize('status',[301,302,307,308,401,403,500])
def test_transport_does_not_follow_or_render_provider_failure(server,status):
url,state=server;state['status']=status;state['raw']=b'private-provider-error'
code,body=p.request(url+'/auth',{'password':'synthetic-password'},'synthetic-token')
assert code==status and body is None and len(state['requests'])==1
def test_transport_ignores_proxy_and_preserves_bodyless_get(server,monkeypatch):
url,state=server
for name in ['http_proxy','HTTP_PROXY']:monkeypatch.setenv(name,'http://127.0.0.1:1')
for name in ['no_proxy','NO_PROXY']:monkeypatch.setenv(name,'')
assert p.request(url+'/realm/',token='fixture-token')[0]==200
assert not state['requests'][0][2] and 'Content-Type' not in state['requests'][0][1]
@pytest.mark.parametrize('raw',[b'x'*(p.MAX_BODY+1),b'{"result":{"status":true,"status":false}}',b'bad-json'],ids=['oversized','duplicate','invalid'])
def test_transport_invalid_response_is_not_forwarded(server,raw):
url,state=server;state['raw']=raw
assert p.request(url+'/realm/')[1] is None
def test_transport_refuses_unapproved_destination():
assert p.request('https://other.invalid/auth',{'password':'synthetic'})==(0,None)
@pytest.fixture
def private_root(tmp_path):
for component,key in [('privacyidea','PI_ADMIN_PASSWORD'),('lldap','LLDAP_LDAP_USER_PASS')]:
directory=tmp_path/component;directory.mkdir(mode=0o700)
f=directory/'secrets.env';f.write_text(key+'=literal-$(never-execute)-quote\'"\\end\n');f.chmod(0o600)
return tmp_path
def test_private_env_is_literal_not_shell(private_root):
a,b=r.private_inputs(private_root);assert a==b=="literal-$(never-execute)-quote'\"\\end"
@pytest.mark.parametrize('kind',['symlink','hardlink','fifo','public-file','public-dir','duplicate','invalid'])
def test_private_input_rejected_before_network(private_root,server,kind):
f=private_root/'privacyidea/secrets.env'
if kind=='symlink':f.rename(f.with_suffix('.real'));f.symlink_to(f.with_suffix('.real'))
elif kind=='hardlink':os.link(f,f.with_suffix('.link'))
elif kind=='fifo':f.unlink();os.mkfifo(f,0o600)
elif kind=='public-file':f.chmod(0o644)
elif kind=='public-dir':f.parent.chmod(0o755)
elif kind=='duplicate':f.write_text('PI_ADMIN_PASSWORD=one\nPI_ADMIN_PASSWORD=two\n')
elif kind=='invalid':f.write_text('touch forbidden-marker\n')
url,state=server
proc=subprocess.run(['bash','-x',str(HELPERS/'bootstrap-realm.sh'),str(private_root),url,'--apply'],capture_output=True,text=True,timeout=10)
assert proc.returncode==1 and not proc.stdout and not state['requests']
assert 'literal-' not in proc.stderr and str(f) not in proc.stderr
def test_attended_wrapper_refuses_without_a_terminal():
proc=subprocess.run(['bash','-x',str(HELPERS/'repair-realm-live.sh')],stdin=subprocess.DEVNULL,capture_output=True,text=True)
assert proc.returncode==1 and not proc.stdout and '"phase": "input"' in proc.stderr
def test_real_provider_merge_and_policy_preservation():
pytest.importorskip('privacyidea')
proc=subprocess.run([sys.executable,str(ROOT/'tests/realm_bootstrap_native_fixture.py')],capture_output=True,text=True,timeout=120)
assert proc.returncode==0,proc.stdout
result=json.loads(proc.stdout);assert len(result)==6 and all(v is True for v in result.values())
@pytest.mark.parametrize('role',[None,'user'])
def test_login_must_confirm_admin_role(server,role):
url,state=server;state['response']={'result':{'status':True,'value':{'token':'fixture-token','role':role}}}
with pytest.raises(r.Refused):r.Client(url,'fixture-password')
assert len(state['requests'])==1 and state['requests'][0][0]=='/auth'
def test_default_readback_refuses_unexpected_other_realm():
class Concurrent(Fake):
def call(self,path,payload):
value=super().call(path,payload)
if path=='/defaultrealm/coulomb':self.state['realms']['unreviewed']={'default':False,'resolver':[]}
return value
client=Concurrent({'resolver':None,'realms':{}})
with pytest.raises(r.Refused):r.repair(client,lambda:'fixture-bind',apply=True)
assert len(client.calls)==3

View file

@ -158,11 +158,12 @@ recovery codes in this UI.
If the live privacyIDEA instance has lost the `coulomb` realm, LLDAP resolver,
or self-service policies, open **Usecases & Runbooks** and copy **Repair
privacyIDEA realm and self-service**. The action is attended: it prompts for
the `pi-admin` password and the LLDAP bind/admin password, writes them only to a
private temporary directory, runs
`sso-mfa/k8s/privacyidea/repair-realm-live.sh`, removes the temporary files on
exit, and then runs `sso-mfa/k8s/verify-t06.sh`. The UI does not store either
privacyIDEA realm and resolver**. The action is attended: it prompts for
the `pi-admin` password and, only when the resolver is absent, its LLDAP bind
password. `sso-mfa/k8s/privacyidea/repair-realm-live.sh` keeps them in memory,
repairs missing core configuration without replaying MFA policies, and runs the
same functional proof as `verify-t06.sh`. Missing onboarding policies require
their existing owner procedure; this action does not recreate them. The UI does not store either
password, and TOTP enrollment or re-enrollment remains a human step in
`https://pink-account.coulomb.social`.

View file

@ -3158,8 +3158,8 @@ def runbook_command_payloads(data: dict[str, Any]) -> 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(

View file

@ -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