Close OpenBao OIDC admin bootstrap path

This commit is contained in:
2026-06-01 21:20:53 +02:00
parent ed2cc17165
commit c48e076429
15 changed files with 374 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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