feat(privacyidea): add guarded resolver reconciliation helper
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02929-244b-7391-b933-c04010e8eedb
This commit is contained in:
parent
edee5c28ab
commit
f2e578cf3e
3 changed files with 137 additions and 0 deletions
|
|
@ -126,6 +126,12 @@ material.
|
||||||
- A named operator window, driver, abort operator, provider authority, and
|
- A named operator window, driver, abort operator, provider authority, and
|
||||||
protected verification method are still required before any live action.
|
protected verification method are still required before any live action.
|
||||||
|
|
||||||
|
NetKingdom added
|
||||||
|
`sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh` as the bounded
|
||||||
|
resolver-only operation. It requires explicit `--apply` plus an interactive
|
||||||
|
terminal, passes password files by pathname to a child process, updates only
|
||||||
|
`lldap-coulomb`, and emits only a boolean result. It has not been run.
|
||||||
|
|
||||||
## Owner-reported cutover outcome
|
## Owner-reported cutover outcome
|
||||||
|
|
||||||
KeyCape reported on 2026-08-23 that its governed recovery path completed the
|
KeyCape reported on 2026-08-23 that its governed recovery path completed the
|
||||||
|
|
|
||||||
125
sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh
Executable file
125
sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh
Executable file
|
|
@ -0,0 +1,125 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# update-lldap-resolver-live.sh — attended, resolver-only privacyIDEA update.
|
||||||
|
#
|
||||||
|
# This does not repair realms or policies. It updates only the persisted
|
||||||
|
# lldap-coulomb resolver after the LLDAP bind credential has changed.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./update-lldap-resolver-live.sh --apply
|
||||||
|
#
|
||||||
|
# The operator supplies both passwords interactively. Values are kept in a
|
||||||
|
# mode-0700 temporary directory and mode-0600 files, passed to a child process
|
||||||
|
# by pathname, and never printed or placed in command arguments.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" != "--apply" || "${2:-}" != "" ]]; then
|
||||||
|
echo "Usage: $0 --apply" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
echo "ERROR: --apply requires an interactive terminal." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
PI_URL="${PI_URL:-https://pink.coulomb.social}"
|
||||||
|
RESOLVER_NAME="${RESOLVER_NAME:-lldap-coulomb}"
|
||||||
|
LLDAP_URL="${LLDAP_URL:-ldap://lldap.sso.svc.cluster.local:3890}"
|
||||||
|
LLDAP_BASE_DN="${LLDAP_BASE_DN:-dc=netkingdom,dc=local}"
|
||||||
|
LLDAP_BIND_DN="${LLDAP_BIND_DN:-uid=admin,ou=people,dc=netkingdom,dc=local}"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
chmod 700 "$tmp"
|
||||||
|
cleanup() {
|
||||||
|
if [[ -f "$tmp/pi-admin" ]]; then shred -u "$tmp/pi-admin" 2>/dev/null || rm -f "$tmp/pi-admin"; fi
|
||||||
|
if [[ -f "$tmp/lldap-bind" ]]; then shred -u "$tmp/lldap-bind" 2>/dev/null || rm -f "$tmp/lldap-bind"; fi
|
||||||
|
rmdir "$tmp" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
printf 'privacyIDEA pi-admin password: ' >&2
|
||||||
|
IFS= read -r -s pi_admin_password
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'LLDAP bind/admin password: ' >&2
|
||||||
|
IFS= read -r -s lldap_bind_password
|
||||||
|
printf '\n' >&2
|
||||||
|
if [[ -z "$pi_admin_password" || -z "$lldap_bind_password" ]]; then
|
||||||
|
echo "ERROR: passwords must not be empty." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
printf '%s' "$pi_admin_password" > "$tmp/pi-admin"
|
||||||
|
printf '%s' "$lldap_bind_password" > "$tmp/lldap-bind"
|
||||||
|
unset pi_admin_password lldap_bind_password
|
||||||
|
chmod 600 "$tmp/pi-admin" "$tmp/lldap-bind"
|
||||||
|
|
||||||
|
python3 - "$tmp/pi-admin" "$tmp/lldap-bind" "$PI_URL" "$RESOLVER_NAME" \
|
||||||
|
"$LLDAP_URL" "$LLDAP_BASE_DN" "$LLDAP_BIND_DN" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
pi_path, ldap_path, base_url, resolver, ldap_url, base_dn, bind_dn = sys.argv[1:]
|
||||||
|
|
||||||
|
def read_secret(path: str) -> str:
|
||||||
|
value = Path(path).read_text(encoding="utf-8")
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError("empty protected input")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def post(path: str, payload: dict, token: str | None = None) -> dict:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if token is not None:
|
||||||
|
headers["Authorization"] = token
|
||||||
|
request = urllib.request.Request(
|
||||||
|
base_url.rstrip("/") + path,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers=headers,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=20) as response:
|
||||||
|
return json.load(response)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise RuntimeError(f"HTTP status {exc.code}") from None
|
||||||
|
except (urllib.error.URLError, TimeoutError):
|
||||||
|
raise RuntimeError("request failed") from None
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth = post("/auth", {"username": "pi-admin", "password": read_secret(pi_path)})
|
||||||
|
token = str(auth.get("result", {}).get("value", {}).get("token", ""))
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("privacyIDEA authentication failed")
|
||||||
|
|
||||||
|
resolver_body = {
|
||||||
|
"type": "ldapresolver",
|
||||||
|
"LDAPURI": ldap_url,
|
||||||
|
"BINDDN": bind_dn,
|
||||||
|
"BINDPW": read_secret(ldap_path),
|
||||||
|
"LDAPBASE": base_dn,
|
||||||
|
"LOGINNAMEATTRIBUTE": "uid",
|
||||||
|
"LDAPSEARCHFILTER": "(objectClass=inetOrgPerson)",
|
||||||
|
"LDAPFILTER": "(&(objectClass=inetOrgPerson)(uid=%s))",
|
||||||
|
"USERINFO": json.dumps({
|
||||||
|
"username": "uid",
|
||||||
|
"phone": "telephoneNumber",
|
||||||
|
"mobile": "mobile",
|
||||||
|
"email": "mail",
|
||||||
|
"surname": "sn",
|
||||||
|
"givenname": "givenName",
|
||||||
|
}),
|
||||||
|
"UIDTYPE": "uid",
|
||||||
|
"NOREFERRALS": True,
|
||||||
|
"NOSCHEMAS": True,
|
||||||
|
}
|
||||||
|
result = post(f"/resolver/{resolver}", resolver_body, token)
|
||||||
|
status = result.get("result", {}).get("status")
|
||||||
|
if status not in (True, "true", "True"):
|
||||||
|
raise RuntimeError("privacyIDEA resolver update was rejected")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print("privacyIDEA resolver update: PASS")
|
||||||
|
PY
|
||||||
|
|
@ -117,6 +117,12 @@ clients are a list, so the verifier now uses Authelia's supported template
|
||||||
filter over the Secret-mounted file. The revision was applied live and
|
filter over the Secret-mounted file. The revision was applied live and
|
||||||
Authelia returned Ready with startup complete.
|
Authelia returned Ready with startup complete.
|
||||||
|
|
||||||
|
NetKingdom also added the unattended-safe shape of the remaining provider
|
||||||
|
operation in `sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh`. It is
|
||||||
|
explicitly gated by `--apply`, requires an interactive terminal, uses protected
|
||||||
|
0600 files, updates only `lldap-coulomb`, and emits no credential values. It
|
||||||
|
has not been run; attended provider-admin reconciliation remains pending.
|
||||||
|
|
||||||
Do not use `sso-mfa/bootstrap/creds-rotate.sh` through an agent as currently
|
Do not use `sso-mfa/bootstrap/creds-rotate.sh` through an agent as currently
|
||||||
written: it prints generated replacement values and its signing-key path
|
written: it prints generated replacement values and its signing-key path
|
||||||
causes immediate invalidation. Do not use a helper that reads the live Secret
|
causes immediate invalidation. Do not use a helper that reads the live Secret
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue