generated from coulomb/repo-seed
Close OpenBao OIDC admin bootstrap path
This commit is contained in:
@@ -114,7 +114,7 @@ data:
|
||||
- groups
|
||||
grant_types:
|
||||
- authorization_code
|
||||
token_endpoint_auth_method: client_secret_post
|
||||
token_endpoint_auth_method: client_secret_basic
|
||||
response_types:
|
||||
- code
|
||||
response_modes:
|
||||
|
||||
@@ -98,6 +98,21 @@ bash ./create-secrets.sh
|
||||
kubectl rollout restart deployment/keycape -n sso
|
||||
```
|
||||
|
||||
If the browser flow reaches the KeyCape OTP screen and then reports
|
||||
`mfa check error`, refresh the live privacyIDEA token without printing it:
|
||||
|
||||
```bash
|
||||
cd sso-mfa/k8s/keycape
|
||||
KEYCAPE_PI_REALM=coulomb KUBECTL="${KUBECTL:-kubectl}" \
|
||||
bash ./refresh-pi-token-live.sh platform-root
|
||||
```
|
||||
|
||||
The helper prompts for the `pi-admin` password, writes the token only into
|
||||
Kubernetes Secrets, and restarts KeyCape. The current live privacyIDEA realm is
|
||||
`coulomb`; use `KEYCAPE_PI_REALM=netkingdom` only for an explicit future realm
|
||||
migration. The helper also restores `privacyidea.requireForAll: true`, which
|
||||
keeps KeyCape from using the admin token-list API as the MFA-required check.
|
||||
|
||||
## OIDC client registration
|
||||
|
||||
Downstream applications are registered in the `clients:` block in
|
||||
|
||||
@@ -42,6 +42,9 @@ OPENBAO_POD="${OPENBAO_POD:-openbao-0}"
|
||||
oidc_client_secret="keycape-public-pkce-compatibility-value" \
|
||||
default_role="platform-admin"
|
||||
|
||||
# Keep array-valued groups in groups_claim/bound_claims only. OpenBao
|
||||
# claim_mappings copy scalar claim values into metadata and will fail if the
|
||||
# groups array is mapped there.
|
||||
cat >/tmp/openbao-platform-admin-role.json <<'"'"'ROLE_JSON'"'"'
|
||||
{
|
||||
"role_type": "oidc",
|
||||
@@ -57,8 +60,7 @@ OPENBAO_POD="${OPENBAO_POD:-openbao-0}"
|
||||
},
|
||||
"claim_mappings": {
|
||||
"email": "email",
|
||||
"preferred_username": "username",
|
||||
"groups": "groups"
|
||||
"preferred_username": "username"
|
||||
},
|
||||
"policies": ["platform-admin"],
|
||||
"ttl": "1h"
|
||||
|
||||
@@ -54,7 +54,7 @@ spec:
|
||||
# 2026-05-24: direct-imported into railiance01 k3s for the
|
||||
# bootstrap-console OIDC/MFA rollout. Use IfNotPresent while the
|
||||
# HTTP registry push/pull path is being cleaned up.
|
||||
image: 92.205.130.254:32166/coulomb/key-cape:main-06d20c3
|
||||
image: 92.205.130.254:32166/coulomb/key-cape:main-nonce-0601
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
ports:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch or verify the KeyCape openbao-admin client in a live Secret.
|
||||
"""Patch or verify non-secret KeyCape live config requirements.
|
||||
|
||||
The script reads a Kubernetes Secret JSON object from stdin. It never prints the
|
||||
decoded KeyCape config or private key; stdout is either a JSON merge patch for
|
||||
@@ -32,6 +32,11 @@ OPENBAO_CLIENT = {
|
||||
"clientType": "public",
|
||||
}
|
||||
|
||||
LLDAP_REQUIRED = {
|
||||
"userOU": "ou=people",
|
||||
"groupOU": "ou=groups",
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
secret = json.load(sys.stdin)
|
||||
@@ -83,8 +88,28 @@ def upsert_client(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return config
|
||||
|
||||
|
||||
def lldap_errors(config: dict[str, Any]) -> list[str]:
|
||||
lldap = config.get("lldap")
|
||||
if not isinstance(lldap, dict):
|
||||
return ["lldap must be a mapping"]
|
||||
return [
|
||||
f"lldap.{key} should be {expected!r}"
|
||||
for key, expected in LLDAP_REQUIRED.items()
|
||||
if lldap.get(key) != expected
|
||||
]
|
||||
|
||||
|
||||
def enforce_lldap_defaults(config: dict[str, Any]) -> dict[str, Any]:
|
||||
lldap = config.get("lldap")
|
||||
if not isinstance(lldap, dict):
|
||||
lldap = {}
|
||||
config["lldap"] = lldap
|
||||
lldap.update(LLDAP_REQUIRED)
|
||||
return config
|
||||
|
||||
|
||||
def render_patch(config: dict[str, Any]) -> None:
|
||||
updated = upsert_client(config)
|
||||
updated = enforce_lldap_defaults(upsert_client(config))
|
||||
config_text = yaml.safe_dump(updated, sort_keys=False)
|
||||
encoded = base64.b64encode(config_text.encode("utf-8")).decode("ascii")
|
||||
json.dump({"data": {"config.yaml": encoded}}, sys.stdout, separators=(",", ":"))
|
||||
@@ -92,12 +117,12 @@ def render_patch(config: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def verify(config: dict[str, Any]) -> None:
|
||||
errors = client_errors(config)
|
||||
errors = client_errors(config) + lldap_errors(config)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"[FAIL] {error}")
|
||||
raise SystemExit(1)
|
||||
print("[PASS] openbao-admin client has required CLI redirects, scopes, grant type, and public PKCE profile")
|
||||
print("[PASS] openbao-admin client and LLDAP OU lookup settings are present")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Patch the live KeyCape config Secret with the code-defined OpenBao CLI client.
|
||||
# Patch the live KeyCape config Secret with non-secret code-defined settings:
|
||||
# the OpenBao CLI client and LLDAP OU lookup paths.
|
||||
# This does not require decrypted bootstrap secrets and does not print existing
|
||||
# Secret values.
|
||||
|
||||
@@ -14,4 +15,4 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
| python3 "$SCRIPT_DIR/openbao-client-config.py" patch \
|
||||
| "$KUBECTL" patch secret "$SECRET" -n "$NAMESPACE" --type merge --patch-file /dev/stdin
|
||||
|
||||
echo "Patched $NAMESPACE/$SECRET with the openbao-admin client definition."
|
||||
echo "Patched $NAMESPACE/$SECRET with the openbao-admin client and LLDAP OU lookup settings."
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
#
|
||||
# Optional environment:
|
||||
# KUBECTL=/path/to/kubectl
|
||||
# KEYCAPE_PI_REALM=coulomb|netkingdom
|
||||
# KEYCAPE_PI_REALM=coulomb|netkingdom # defaults to coulomb, the live privacyIDEA realm
|
||||
# KEYCAPE_PI_REQUIRE_FOR_ALL=true|false # defaults to true to avoid admin token-list checks
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -94,49 +95,67 @@ current_config="$(
|
||||
current_realm="$(
|
||||
CONFIG_YAML="$current_config" python3 -c '
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
match = re.search(r"(?m)^ realm:\s*[\"'\'']?([^\"'\'']+)", os.environ["CONFIG_YAML"])
|
||||
print(match.group(1).strip() if match else "")
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("PyYAML is required: install python3-yaml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {}
|
||||
privacyidea = config.get("privacyidea") or {}
|
||||
if not isinstance(privacyidea, dict):
|
||||
print("")
|
||||
else:
|
||||
print(str(privacyidea.get("realm") or "").strip())
|
||||
'
|
||||
)"
|
||||
|
||||
selected_realm="${KEYCAPE_PI_REALM:-}"
|
||||
if [[ -z "$selected_realm" && -n "$current_realm" ]]; then
|
||||
selected_realm="$current_realm"
|
||||
fi
|
||||
if [[ -z "$selected_realm" ]]; then
|
||||
selected_realm="coulomb"
|
||||
selected_realm="${KEYCAPE_PI_REALM:-coulomb}"
|
||||
selected_require_for_all="${KEYCAPE_PI_REQUIRE_FOR_ALL:-true}"
|
||||
if [[ -z "${KEYCAPE_PI_REALM:-}" && -n "$current_realm" && "$current_realm" != "$selected_realm" ]]; then
|
||||
echo "[WARN] KeyCape currently points privacyIDEA at realm '$current_realm'; repairing to '$selected_realm'." >&2
|
||||
fi
|
||||
|
||||
if [[ "$selected_realm" != "coulomb" && "$selected_realm" != "netkingdom" ]]; then
|
||||
echo "[FAIL] Refusing unsupported privacyIDEA realm: $selected_realm" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$selected_require_for_all" != "true" && "$selected_require_for_all" != "false" ]]; then
|
||||
echo "[FAIL] KEYCAPE_PI_REQUIRE_FOR_ALL must be true or false." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Selected privacyIDEA realm for KeyCape: $selected_realm"
|
||||
echo "Selected privacyIDEA requireForAll for KeyCape: $selected_require_for_all"
|
||||
|
||||
"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \
|
||||
-o jsonpath='{.data.key\.pem}' | base64 -d > "$tmpdir/key.pem"
|
||||
chmod 600 "$tmpdir/key.pem"
|
||||
|
||||
CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" \
|
||||
CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" PI_REQUIRE_FOR_ALL="$selected_require_for_all" \
|
||||
python3 -c '
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
config = os.environ["CONFIG_YAML"]
|
||||
token = json.dumps(os.environ["PI_TOKEN"])
|
||||
realm = json.dumps(os.environ["PI_REALM"])
|
||||
|
||||
config, token_count = re.subn(r"(?m)^ adminToken:.*$", " adminToken: " + token, config)
|
||||
config, realm_count = re.subn(r"(?m)^ realm:.*$", " realm: " + realm, config)
|
||||
if token_count != 1 or realm_count != 1:
|
||||
print("Could not patch exactly one adminToken and one realm field.", file=sys.stderr)
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("PyYAML is required: install python3-yaml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
sys.stdout.write(config)
|
||||
|
||||
config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {}
|
||||
if not isinstance(config, dict):
|
||||
print("KeyCape config.yaml must decode to a YAML mapping.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
privacyidea = config.setdefault("privacyidea", {})
|
||||
if not isinstance(privacyidea, dict):
|
||||
print("KeyCape privacyidea config must decode to a YAML mapping.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
privacyidea["adminToken"] = os.environ["PI_TOKEN"]
|
||||
privacyidea["realm"] = os.environ["PI_REALM"]
|
||||
privacyidea["requireForAll"] = os.environ["PI_REQUIRE_FOR_ALL"] == "true"
|
||||
sys.stdout.write(yaml.safe_dump(config, sort_keys=False))
|
||||
' > "$tmpdir/config.yaml"
|
||||
|
||||
echo "Applying refreshed KeyCape config Secret ..."
|
||||
|
||||
@@ -68,11 +68,13 @@ fi
|
||||
# ── Authenticate ──────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Authenticating to privacyIDEA at $PI_URL ..."
|
||||
AUTH_RESPONSE=$(curl -sf -X POST "$PI_URL/auth" \
|
||||
if ! AUTH_RESPONSE=$(PI_ADMIN_PASS="$PI_ADMIN_PASS" python3 -c '
|
||||
import json
|
||||
import os
|
||||
print(json.dumps({"username": "pi-admin", "password": os.environ["PI_ADMIN_PASS"]}))
|
||||
' | curl -sS -X POST "$PI_URL/auth" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"pi-admin\",\"password\":\"$PI_ADMIN_PASS\"}" 2>/dev/null || echo "CURL_FAILED")
|
||||
|
||||
if [[ "$AUTH_RESPONSE" == "CURL_FAILED" ]]; then
|
||||
--data-binary @- 2>/dev/null); then
|
||||
echo "ERROR: Could not reach $PI_URL — is the cluster up and privacyIDEA running?" >&2
|
||||
echo " Run verify-t04.sh to diagnose." >&2
|
||||
exit 1
|
||||
@@ -94,10 +96,10 @@ pi_api() {
|
||||
# BadRequest if Content-Type: application/json is sent on a bodyless GET.
|
||||
local method="$1"; local path="$2"; local body="${3:-}"
|
||||
if [[ -n "$body" ]]; then
|
||||
curl -sf -X "$method" "$PI_URL$path" \
|
||||
printf '%s' "$body" | curl -sf -X "$method" "$PI_URL$path" \
|
||||
-H "Authorization: $PI_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" 2>/dev/null || echo "CURL_FAILED"
|
||||
--data-binary @- 2>/dev/null || echo "CURL_FAILED"
|
||||
else
|
||||
curl -sf -X "$method" "$PI_URL$path" \
|
||||
-H "Authorization: $PI_TOKEN" \
|
||||
@@ -136,13 +138,13 @@ echo "Step 1: Creating LDAP resolver '$RESOLVER_NAME' ..."
|
||||
# LLDAP uses standard inetOrgPerson attributes.
|
||||
USERINFO='{"username": "uid", "phone": "telephoneNumber", "mobile": "mobile", "email": "mail", "surname": "sn", "givenname": "givenName"}'
|
||||
|
||||
RESOLVER_BODY=$(python3 -c "
|
||||
import json, sys
|
||||
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': sys.argv[1],
|
||||
'BINDPW': os.environ['LLDAP_BIND_PW'],
|
||||
'LDAPBASE': '$LLDAP_BASE_DN',
|
||||
'LOGINNAMEATTRIBUTE': 'uid',
|
||||
'LDAPSEARCHFILTER': '(objectClass=inetOrgPerson)',
|
||||
@@ -153,7 +155,7 @@ body = {
|
||||
'NOSCHEMAS': True
|
||||
}
|
||||
print(json.dumps(body))
|
||||
" "$LLDAP_BIND_PW")
|
||||
")
|
||||
|
||||
RESP=$(pi_api POST "/resolver/$RESOLVER_NAME" "$RESOLVER_BODY")
|
||||
check_result "LDAP resolver '$RESOLVER_NAME' created/updated" "$RESP" || true
|
||||
|
||||
56
sso-mfa/k8s/privacyidea/repair-realm-live.sh
Executable file
56
sso-mfa/k8s/privacyidea/repair-realm-live.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/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.
|
||||
|
||||
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
|
||||
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"
|
||||
|
||||
if ! bash "$SSO_MFA_K8S_DIR/verify-t06.sh" "$tmp"; then
|
||||
cat >&2 <<'WARN'
|
||||
|
||||
[WARN] verify-t06 still reports failures. If realm, resolver, policies, and
|
||||
self-service pass but KeyCape token checks fail, run the KeyCape privacyIDEA
|
||||
MFA token repair action after platform-root enrollment.
|
||||
WARN
|
||||
fi
|
||||
|
||||
cat <<'OK'
|
||||
|
||||
[OK] privacyIDEA coulomb realm repair command finished. Enroll or re-enroll
|
||||
platform-root TOTP in privacyIDEA next.
|
||||
OK
|
||||
Reference in New Issue
Block a user