security: preserve realm configuration and remove shell credential evaluation
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
parent
96feef46ab
commit
30f0d27840
16 changed files with 700 additions and 430 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" "$@"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
190
sso-mfa/k8s/privacyidea/realm_bootstrap.py
Normal file
190
sso-mfa/k8s/privacyidea/realm_bootstrap.py
Normal 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())
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
84
tests/realm_bootstrap_native_fixture.py
Normal file
84
tests/realm_bootstrap_native_fixture.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
207
tests/test_realm_bootstrap.py
Normal file
207
tests/test_realm_bootstrap.py
Normal 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
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue