Validate cadence contract and require functional MFA verification
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ea3-7939-7b63-8125-699f8b50bedd
This commit is contained in:
parent
d4d61b722e
commit
4e07d60ff1
34 changed files with 1640 additions and 364 deletions
|
|
@ -1,5 +1,7 @@
|
|||
# T02 — K8s Foundations
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../docs/attended-procedure-inventory.md).
|
||||
|
||||
Phase 1 of NK-WP-0001: namespaces, NetworkPolicies, cert-manager, StorageClass.
|
||||
|
||||
## SSO stack overview
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# T05b — Authelia (Authentication Frontend)
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
|
||||
|
||||
Authelia is the password-authentication frontend for the net-kingdom SSO stack.
|
||||
It acts as an upstream OIDC provider for KeyCape: users are redirected here to
|
||||
enter their password; Authelia validates credentials against LLDAP and returns
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Disaster Recovery Runbook — net-kingdom SSO/MFA Platform
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
|
||||
|
||||
**Stack:** LLDAP + Authelia + KeyCape (sso namespace) + privacyIDEA (mfa namespace)
|
||||
**PostgreSQL:** Managed separately by CNPG (`postgresql/scheduled-backup.yaml`)
|
||||
|
||||
|
|
@ -171,7 +173,7 @@ cd ../keycape && ./create-pi-token.sh && ./create-secrets.sh
|
|||
kubectl rollout restart deployment/keycape -n sso
|
||||
|
||||
# 7. Verify
|
||||
./verify-t04.sh && ./verify-t05.sh && ./verify-t06.sh && ./verify-t07.sh && ./verify-t08.sh
|
||||
./verify-t04.sh && ./verify-t05.sh && ./verify-t06.sh --user platform-root && ./verify-t07.sh && ./verify-t08.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# T05c — KeyCape (OIDC Orchestration Layer)
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
|
||||
|
||||
KeyCape is the stateless OIDC server that ties the stack together. It orchestrates
|
||||
the full authentication flow:
|
||||
1. User visits a registered application
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Operator groups — membership runbook
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
|
||||
|
||||
Least-privilege **app-scoped** LLDAP groups that gate browser SSO via Authelia
|
||||
domain rules. Distinct from platform groups (`net-kingdom-users` /
|
||||
`net-kingdom-admins`).
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# T05a — LLDAP (Lightweight LDAP Directory)
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
|
||||
|
||||
LLDAP is the user and group directory for the net-kingdom SSO stack. It provides
|
||||
LDAP access to Authelia (credential validation) and KeyCape (user attribute lookup).
|
||||
The admin web UI is IP-restricted and never exposed publicly.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# T03 — PostgreSQL (CloudNativePG)
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
|
||||
|
||||
Phase 2 of NK-WP-0001: CloudNativePG cluster with `keycloak_db` and `privacyidea_db`.
|
||||
|
||||
## Prerequisites
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# T04 — Phase 3: Deploy privacyIDEA
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
|
||||
|
||||
Phase 3 of NK-WP-0001: deploys the MFA core (privacyIDEA) in the `mfa` namespace.
|
||||
|
||||
**Hostnames (config points CP-NK-002 / CP-NK-003):**
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
|
||||
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}"
|
||||
|
|
@ -46,8 +48,8 @@ LDAP_SIZELIMIT="${LDAP_SIZELIMIT:-500}"
|
|||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
ok() { echo " [OK] $1"; ((PASS_COUNT++)); }
|
||||
fail() { echo " [FAIL] $1"; ((FAIL_COUNT++)); }
|
||||
ok() { echo " [OK] $1"; PASS_COUNT=$((PASS_COUNT + 1)); }
|
||||
fail() { echo " [FAIL] $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
|
||||
info() { echo " [INFO] $1"; }
|
||||
|
||||
# ── Validate secrets ──────────────────────────────────────────────────────────
|
||||
|
|
@ -78,12 +80,10 @@ 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"]}))
|
||||
' | curl -sS -X POST "$PI_URL/auth" \
|
||||
-H "Content-Type: application/json" \
|
||||
--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
|
||||
' | python3 "$PI_HELPER" POST "$PI_URL/auth"); then
|
||||
echo "ERROR: privacyIDEA authentication request failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -92,26 +92,14 @@ PI_TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c \
|
|||
|
||||
if [[ -z "$PI_TOKEN" ]]; then
|
||||
echo "ERROR: Authentication failed — check pi-admin credentials and MFA enrollment." >&2
|
||||
echo " Response: $AUTH_RESPONSE" >&2
|
||||
exit 1
|
||||
fi
|
||||
info "Authenticated as pi-admin (token obtained)"
|
||||
|
||||
pi_api() {
|
||||
# pi_api <method> <path> [json-body]
|
||||
# Content-Type is only set on requests with a body — Werkzeug 3.x raises
|
||||
# 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
|
||||
printf '%s' "$body" | curl -sf -X "$method" "$PI_URL$path" \
|
||||
-H "Authorization: $PI_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- 2>/dev/null || echo "CURL_FAILED"
|
||||
else
|
||||
curl -sf -X "$method" "$PI_URL$path" \
|
||||
-H "Authorization: $PI_TOKEN" \
|
||||
2>/dev/null || echo "CURL_FAILED"
|
||||
fi
|
||||
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() {
|
||||
|
|
@ -332,7 +320,7 @@ echo " WebUI → Config → Policies → mfa-passthru-phase1 → set activ
|
|||
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"
|
||||
echo "Next step: ../verify-t06.sh --user platform-root"
|
||||
|
||||
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
||||
exit 1
|
||||
|
|
|
|||
56
sso-mfa/k8s/privacyidea/pi_api.py
Normal file
56
sso-mfa/k8s/privacyidea/pi_api.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Shared JSON request transport. Credentials stay in memory or stdin, never argv."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
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
|
||||
except urllib.error.HTTPError as exc:
|
||||
exc.close()
|
||||
return exc.code, None
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
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")
|
||||
return 1
|
||||
print(json.dumps(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -121,7 +121,8 @@ else
|
|||
fi
|
||||
prompt_secret "one-time MFA code for $MFA_USER@$MFA_REALM" "$tmp/otp"
|
||||
|
||||
if python3 - "$tmp/pi-admin" "$tmp/lldap-new" "$tmp/lldap-old" "$tmp/otp" \
|
||||
PI_HELPER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if PYTHONPATH="$PI_HELPER_DIR${PYTHONPATH:+:$PYTHONPATH}" python3 - "$tmp/pi-admin" "$tmp/lldap-new" "$tmp/lldap-old" "$tmp/otp" \
|
||||
"$PI_URL" "$LLDAP_AUTH_URL" "$LLDAP_URL" "$LLDAP_BASE_DN" "$LLDAP_BIND_DN" \
|
||||
"$RESOLVER_NAME" "$MFA_USER" "$MFA_REALM" "$KEYCAPE_DISCOVERY_URL" "$MODE" "$tmp/phase" "$PREDECESSOR" \
|
||||
"$LDAP_TIMEOUT" "$LDAP_CACHE_TIMEOUT" "$LDAP_SIZELIMIT" <<'PY'
|
||||
|
|
@ -148,33 +149,7 @@ def secret(path: str) -> str:
|
|||
raise RuntimeError("empty protected input")
|
||||
return value
|
||||
|
||||
def request(url: str, payload: dict | None = None, token: str | None = None) -> tuple[int, dict | None]:
|
||||
# Content-Type is only set on requests with a body — Werkzeug 3.x raises
|
||||
# BadRequest if Content-Type: application/json is sent on a bodyless GET,
|
||||
# and the rejection happens in front of privacyIDEA, so the reply is an HTML
|
||||
# error page rather than a JSON result. Same fix as bootstrap-realm.sh's
|
||||
# pi_api helper.
|
||||
headers = {}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["Authorization"] = token
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method="POST" if payload is not None else "GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as response:
|
||||
status = response.status
|
||||
body = response.read()
|
||||
if not body:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return status, None
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, None
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 0, None
|
||||
from pi_api import request
|
||||
|
||||
def check_k8s_ready() -> None:
|
||||
workloads = (("sso", "lldap"), ("mfa", "privacyidea"), ("sso", "keycape"),
|
||||
|
|
|
|||
|
|
@ -40,17 +40,6 @@ printf "LLDAP_LDAP_USER_PASS=%q\n" "$LLDAP_LDAP_USER_PASS" > "$tmp/lldap/secrets
|
|||
|
||||
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
|
||||
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."
|
||||
|
|
|
|||
128
sso-mfa/k8s/privacyidea/verify_mfa.py
Normal file
128
sso-mfa/k8s/privacyidea/verify_mfa.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Attended functional privacyIDEA proof for verify-t06.sh."""
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
from pi_api import request
|
||||
|
||||
|
||||
def value(status, body):
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise ValueError("request failed")
|
||||
result = body.get("result")
|
||||
if not isinstance(result, dict) or result.get("status") is not True:
|
||||
raise ValueError("API operation failed")
|
||||
return result.get("value")
|
||||
|
||||
|
||||
def verify(url, user, realm, resolver, password, otp_prompt):
|
||||
phase = "authentication"
|
||||
try:
|
||||
auth = value(
|
||||
*request(url + "/auth", {"username": "pi-admin", "password": password})
|
||||
)
|
||||
token = auth.get("token") if isinstance(auth, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("admin token missing")
|
||||
phase = "realm-binding"
|
||||
realms = value(*request(url + "/realm/", token=token))
|
||||
bindings = realms[realm]["resolver"]
|
||||
if not any(
|
||||
isinstance(item, dict) and item.get("name") == resolver for item in bindings
|
||||
):
|
||||
raise ValueError("realm not bound to expected resolver")
|
||||
phase = "resolver-tuning"
|
||||
resolvers = value(
|
||||
*request(
|
||||
url + "/resolver/" + urllib.parse.quote(resolver, safe=""), token=token
|
||||
)
|
||||
)
|
||||
config = resolvers[resolver]
|
||||
if config.get("type") != "ldapresolver":
|
||||
raise ValueError("wrong resolver type")
|
||||
data = config["data"]
|
||||
for name in ("TIMEOUT", "CACHE_TIMEOUT", "SIZELIMIT"):
|
||||
number = data.get(name)
|
||||
if (
|
||||
isinstance(number, bool)
|
||||
or not isinstance(number, (str, int))
|
||||
or not str(number).isascii()
|
||||
or not str(number).isdigit()
|
||||
):
|
||||
raise ValueError("missing or invalid numeric parameter")
|
||||
phase = "known-user-lookup"
|
||||
query = urllib.parse.urlencode({"realm": realm, "username": user})
|
||||
users = value(*request(url + "/user/?" + query, token=token))
|
||||
if not isinstance(users, list) or not any(
|
||||
isinstance(item, dict)
|
||||
and item.get("username") == user
|
||||
and item.get("resolver") == resolver
|
||||
for item in users
|
||||
):
|
||||
raise ValueError("known user did not resolve through expected resolver")
|
||||
phase = "mfa-validation"
|
||||
otp = otp_prompt()
|
||||
if not otp:
|
||||
raise ValueError("MFA input missing")
|
||||
status, body = request(
|
||||
url + "/validate/check", {"user": user, "realm": realm, "pass": otp}, token
|
||||
)
|
||||
# passthru can return value=true for token-less users. Require a token
|
||||
# serial and type in the successful validation, not password-only success.
|
||||
if value(status, body) is not True:
|
||||
raise ValueError("MFA denied")
|
||||
detail = body.get("detail", {})
|
||||
if not detail.get("serial") or detail.get("type") not in {"totp", "hotp"}:
|
||||
raise ValueError("no token-backed MFA proof")
|
||||
except (OSError, ValueError, TypeError, KeyError, AttributeError, EOFError):
|
||||
return {"result": "FAIL", "phase": phase}
|
||||
return {
|
||||
"result": "PASS",
|
||||
"phase": "complete",
|
||||
"proofs": [
|
||||
"realm-binding",
|
||||
"resolver-tuning",
|
||||
"known-user-lookup",
|
||||
"token-backed-mfa",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--pi-url", default="https://pink.coulomb.social")
|
||||
parser.add_argument("--user", required=True)
|
||||
parser.add_argument("--realm", default="coulomb")
|
||||
parser.add_argument("--resolver", default="lldap-coulomb")
|
||||
args = parser.parse_args(argv)
|
||||
if not sys.stdin.isatty():
|
||||
print(
|
||||
"T06 requires an attended terminal for protected password and fresh MFA input.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
if urllib.parse.urlsplit(args.pi_url).scheme != "https":
|
||||
parser.error("--pi-url must use HTTPS")
|
||||
try:
|
||||
password = getpass.getpass("privacyIDEA pi-admin password: ")
|
||||
if not password:
|
||||
raise ValueError("empty password")
|
||||
report = verify(
|
||||
args.pi_url.rstrip("/"),
|
||||
args.user,
|
||||
args.realm,
|
||||
args.resolver,
|
||||
password,
|
||||
lambda: getpass.getpass("Fresh MFA code (include token PIN if required): "),
|
||||
)
|
||||
except (EOFError, KeyboardInterrupt, ValueError):
|
||||
report = {"result": "FAIL", "phase": "protected-input"}
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 0 if report["result"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
# user-engine portal on reef-railiance
|
||||
|
||||
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
|
||||
|
||||
**Apply home moved.** The managed package is `rapp-user-engine`. Render,
|
||||
deploy, verify, and rollback from that repo (`make deploy`,
|
||||
`make verify-live`). These files remain migration input until that
|
||||
|
|
|
|||
|
|
@ -1,285 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-t06.sh — verify NK-WP-0001-T06 done-criteria
|
||||
#
|
||||
# Checks the MFA flow integration between KeyCape and privacyIDEA.
|
||||
#
|
||||
# Sections:
|
||||
# 1. privacyIDEA pod Running+Ready (namespace: mfa)
|
||||
# 2. privacyIDEA API reachable
|
||||
# 3. Realm "coulomb" exists in privacyIDEA
|
||||
# 4. LDAP resolver "lldap-coulomb" exists
|
||||
# 5. LDAP resolver resolves users (LLDAP connectivity)
|
||||
# 6. KeyCape→privacyIDEA token: valid admin token in keycape-pi-token
|
||||
# 7. KeyCape can list tokens in the coulomb realm
|
||||
# 8. Self-enrollment policy exists
|
||||
# 9. Authentication policy exists
|
||||
# 10. Self-service portal reachable (pink-account.coulomb.social)
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x verify-t06.sh
|
||||
# ./verify-t06.sh [secrets-dir]
|
||||
#
|
||||
# <secrets-dir> default: ../bootstrap/secrets
|
||||
|
||||
# Functional realm/resolver and token-backed MFA proof, NK-WP-0034.
|
||||
# Usage: ./verify-t06.sh --user platform-root
|
||||
# Runbook: docs/verify-t06.md (credentials are prompted, not read from old bundles).
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_DIR="${1:-../bootstrap/secrets}"
|
||||
PI_ENV="$SECRETS_DIR/privacyidea/secrets.env"
|
||||
|
||||
PI_HOST="pink.coulomb.social"
|
||||
PI_URL="https://$PI_HOST"
|
||||
PI_NAMESPACE="mfa"
|
||||
SSO_NAMESPACE="sso"
|
||||
REALM_NAME="coulomb"
|
||||
RESOLVER_NAME="lldap-coulomb"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
pass() { echo " [PASS] $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); }
|
||||
warn() { echo " [WARN] $1"; WARN=$((WARN + 1)); }
|
||||
|
||||
section() { echo ""; echo "── $1 ──────────────────────────────────────"; }
|
||||
|
||||
# ── 1. privacyIDEA pod ────────────────────────────────────────────────────────
|
||||
section "1. privacyIDEA pod (namespace: $PI_NAMESPACE)"
|
||||
PI_POD=$(kubectl get pod -n "$PI_NAMESPACE" \
|
||||
-l app.kubernetes.io/name=privacyidea \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$PI_POD" ]]; then
|
||||
pass "Pod Running: $PI_POD"
|
||||
READY=$(kubectl get pod -n "$PI_NAMESPACE" "$PI_POD" \
|
||||
-o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || echo "false")
|
||||
if [[ "$READY" == "true" ]]; then
|
||||
pass "Pod readiness probe passing"
|
||||
else
|
||||
fail "Pod is Running but not Ready — check logs: kubectl logs -n $PI_NAMESPACE $PI_POD"
|
||||
fi
|
||||
else
|
||||
fail "No Running privacyIDEA pod in namespace '$PI_NAMESPACE' — run verify-t04.sh"
|
||||
fi
|
||||
|
||||
# ── 2. privacyIDEA API reachable ──────────────────────────────────────────────
|
||||
section "2. privacyIDEA API reachable"
|
||||
|
||||
# Authenticate as pi-admin to get a token for subsequent checks.
|
||||
PI_TOKEN=""
|
||||
if [[ -f "$PI_ENV" ]]; then
|
||||
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
|
||||
PI_ADMIN_PASS=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD)
|
||||
|
||||
if [[ -n "$PI_ADMIN_PASS" ]]; then
|
||||
AUTH_RESP=$(curl -sf -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_RESP" != "CURL_FAILED" ]]; then
|
||||
PI_TOKEN=$(echo "$AUTH_RESP" | python3 -c \
|
||||
"import sys,json; print(json.load(sys.stdin)['result']['value']['token'])" \
|
||||
2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$PI_TOKEN" ]]; then
|
||||
pass "privacyIDEA API reachable and pi-admin authenticated"
|
||||
else
|
||||
warn "Could not authenticate to $PI_URL as pi-admin"
|
||||
warn " Ensure $PI_ENV exists and pink.coulomb.social is reachable."
|
||||
warn " Remaining checks that require API access will be skipped."
|
||||
fi
|
||||
|
||||
pi_get() {
|
||||
local path="$1"
|
||||
if [[ -z "$PI_TOKEN" ]]; then echo "NO_TOKEN"; return; fi
|
||||
curl -sf -X GET "$PI_URL$path" \
|
||||
-H "Authorization: $PI_TOKEN" \
|
||||
2>/dev/null || echo "CURL_FAILED"
|
||||
}
|
||||
|
||||
# ── 3. Realm "netkingdom" exists ──────────────────────────────────────────────
|
||||
section "3. Realm '$REALM_NAME' in privacyIDEA"
|
||||
REALM_RESP=$(pi_get "/realm/")
|
||||
if [[ "$REALM_RESP" == "NO_TOKEN" ]]; then
|
||||
warn "Skipping realm check — no API token"
|
||||
elif [[ "$REALM_RESP" == "CURL_FAILED" ]]; then
|
||||
fail "Could not retrieve realm list from $PI_URL"
|
||||
else
|
||||
REALM_EXISTS=$(echo "$REALM_RESP" | python3 -c \
|
||||
"import sys,json; d=json.load(sys.stdin); print('yes' if '$REALM_NAME' in d.get('result',{}).get('value',{}) else 'no')" \
|
||||
2>/dev/null || echo "no")
|
||||
if [[ "$REALM_EXISTS" == "yes" ]]; then
|
||||
pass "Realm '$REALM_NAME' exists"
|
||||
# Check if it is the default realm
|
||||
IS_DEFAULT=$(echo "$REALM_RESP" | python3 -c \
|
||||
"import sys,json; d=json.load(sys.stdin); r=d.get('result',{}).get('value',{}).get('$REALM_NAME',{}); print('yes' if r.get('default') else 'no')" \
|
||||
2>/dev/null || echo "no")
|
||||
if [[ "$IS_DEFAULT" == "yes" ]]; then
|
||||
pass "Realm '$REALM_NAME' is the default realm"
|
||||
else
|
||||
warn "Realm '$REALM_NAME' exists but is not the default realm"
|
||||
warn " Run: POST $PI_URL/defaultrealm/$REALM_NAME"
|
||||
fi
|
||||
else
|
||||
fail "Realm '$REALM_NAME' not found — run bootstrap-realm.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. LDAP resolver exists ───────────────────────────────────────────────────
|
||||
section "4. LDAP resolver '$RESOLVER_NAME'"
|
||||
RESOLVER_RESP=$(pi_get "/resolver/$RESOLVER_NAME")
|
||||
if [[ "$RESOLVER_RESP" == "NO_TOKEN" ]]; then
|
||||
warn "Skipping resolver check — no API token"
|
||||
elif [[ "$RESOLVER_RESP" == "CURL_FAILED" ]]; then
|
||||
fail "Could not retrieve resolver '$RESOLVER_NAME' from $PI_URL"
|
||||
else
|
||||
RESOLVER_TYPE=$(echo "$RESOLVER_RESP" | python3 -c \
|
||||
"import sys,json; d=json.load(sys.stdin); v=d.get('result',{}).get('value',{}).get('data',{}); print(list(v.values())[0].get('type','') if v else '')" \
|
||||
2>/dev/null || echo "")
|
||||
if [[ "$RESOLVER_TYPE" == "ldapresolver" ]]; then
|
||||
pass "Resolver '$RESOLVER_NAME' exists (type: ldapresolver)"
|
||||
elif [[ -z "$RESOLVER_TYPE" ]]; then
|
||||
fail "Resolver '$RESOLVER_NAME' not found — run bootstrap-realm.sh"
|
||||
else
|
||||
warn "Resolver '$RESOLVER_NAME' has unexpected type: '$RESOLVER_TYPE'"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 5. LDAP resolver connectivity (user resolution) ──────────────────────────
|
||||
section "5. LDAP resolver user resolution"
|
||||
# Test resolver by listing users in the netkingdom realm.
|
||||
USERS_RESP=$(pi_get "/user/?realm=$REALM_NAME&pagesize=1")
|
||||
if [[ "$USERS_RESP" == "NO_TOKEN" ]]; then
|
||||
warn "Skipping user resolution check — no API token"
|
||||
elif [[ "$USERS_RESP" == "CURL_FAILED" ]]; then
|
||||
fail "Could not query users in realm '$REALM_NAME' — LDAP resolver may be broken"
|
||||
else
|
||||
USER_COUNT=$(echo "$USERS_RESP" | python3 -c \
|
||||
"import sys,json; d=json.load(sys.stdin); print(len(d.get('result',{}).get('value',{}).get('users',[])))" \
|
||||
2>/dev/null || echo "0")
|
||||
if [[ "$USER_COUNT" -gt 0 ]]; then
|
||||
pass "LDAP resolver resolves users from LLDAP ($USER_COUNT returned in page)"
|
||||
else
|
||||
warn "LDAP resolver returned 0 users — LLDAP may have no users yet, or the resolver may be misconfigured"
|
||||
warn " Check: WebUI → Config → Resolver → $RESOLVER_NAME → [Test]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 6. KeyCape privacyIDEA token ──────────────────────────────────────────────
|
||||
section "6. KeyCape→privacyIDEA admin token"
|
||||
# The token lives in the keycape-pi-token Secret in the sso namespace.
|
||||
# It should have been created by keycape/create-pi-token.sh after T04 bootstrap.
|
||||
if kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" &>/dev/null; then
|
||||
pass "Secret keycape-pi-token exists in namespace $SSO_NAMESPACE"
|
||||
TOKEN_VALUE=$(kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" \
|
||||
-o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
|
||||
if [[ -n "$TOKEN_VALUE" && "$TOKEN_VALUE" != "PENDING_create-pi-token.sh" ]]; then
|
||||
pass "keycape-pi-token contains a non-placeholder token"
|
||||
else
|
||||
fail "keycape-pi-token is a placeholder — run keycape/create-pi-token.sh after T04 bootstrap"
|
||||
fi
|
||||
else
|
||||
fail "Secret keycape-pi-token not found in namespace $SSO_NAMESPACE"
|
||||
fail " Run: cd sso-mfa/k8s/keycape && ./create-pi-token.sh"
|
||||
fi
|
||||
|
||||
# ── 7. KeyCape can list tokens via privacyIDEA API ───────────────────────────
|
||||
section "7. KeyCape→privacyIDEA API connectivity"
|
||||
# Use the keycape-pi-token to call the token list endpoint.
|
||||
KC_PI_TOKEN=$(kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" \
|
||||
-o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$KC_PI_TOKEN" || "$KC_PI_TOKEN" == "PENDING_create-pi-token.sh" ]]; then
|
||||
warn "Skipping connectivity check — keycape-pi-token not populated"
|
||||
else
|
||||
TOKEN_RESP=$(curl -sf -X GET "$PI_URL/token/?realm=$REALM_NAME&pagesize=1" \
|
||||
-H "Authorization: Bearer $KC_PI_TOKEN" \
|
||||
2>/dev/null || echo "CURL_FAILED")
|
||||
if [[ "$TOKEN_RESP" == "CURL_FAILED" ]]; then
|
||||
fail "KeyCape→privacyIDEA: token list request failed (network or auth error)"
|
||||
else
|
||||
STATUS=$(echo "$TOKEN_RESP" | python3 -c \
|
||||
"import sys,json; print(json.load(sys.stdin).get('result',{}).get('status',''))" \
|
||||
2>/dev/null || echo "")
|
||||
if [[ "$STATUS" == "True" || "$STATUS" == "true" ]]; then
|
||||
pass "KeyCape→privacyIDEA: token list API returns status=True"
|
||||
else
|
||||
fail "KeyCape→privacyIDEA: token list API returned unexpected status: '$STATUS'"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 8. Self-enrollment policy ─────────────────────────────────────────────────
|
||||
section "8. Self-enrollment policy"
|
||||
POLICY_RESP=$(pi_get "/policy/totp-self-enrollment")
|
||||
if [[ "$POLICY_RESP" == "NO_TOKEN" ]]; then
|
||||
warn "Skipping policy check — no API token"
|
||||
elif [[ "$POLICY_RESP" == "CURL_FAILED" ]]; then
|
||||
warn "Could not retrieve policy 'totp-self-enrollment'"
|
||||
else
|
||||
POLICY_EXISTS=$(echo "$POLICY_RESP" | python3 -c \
|
||||
"import sys,json; d=json.load(sys.stdin); print('yes' if d.get('result',{}).get('value',{}).get('totp-self-enrollment') else 'no')" \
|
||||
2>/dev/null || echo "no")
|
||||
if [[ "$POLICY_EXISTS" == "yes" ]]; then
|
||||
pass "Policy 'totp-self-enrollment' exists"
|
||||
else
|
||||
warn "Policy 'totp-self-enrollment' not found — run bootstrap-realm.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 9. Authentication policy ──────────────────────────────────────────────────
|
||||
section "9. Authentication policy (passthru phase 1)"
|
||||
POLICY_RESP=$(pi_get "/policy/mfa-passthru-phase1")
|
||||
if [[ "$POLICY_RESP" == "NO_TOKEN" ]]; then
|
||||
warn "Skipping policy check — no API token"
|
||||
elif [[ "$POLICY_RESP" == "CURL_FAILED" ]]; then
|
||||
warn "Could not retrieve policy 'mfa-passthru-phase1'"
|
||||
else
|
||||
POLICY_EXISTS=$(echo "$POLICY_RESP" | python3 -c \
|
||||
"import sys,json; d=json.load(sys.stdin); print('yes' if d.get('result',{}).get('value',{}).get('mfa-passthru-phase1') else 'no')" \
|
||||
2>/dev/null || echo "no")
|
||||
if [[ "$POLICY_EXISTS" == "yes" ]]; then
|
||||
pass "Policy 'mfa-passthru-phase1' exists (passthru for token-less users)"
|
||||
else
|
||||
warn "Policy 'mfa-passthru-phase1' not found — run bootstrap-realm.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 10. Self-service portal reachable ────────────────────────────────────────
|
||||
section "10. Self-service portal (pink-account.coulomb.social)"
|
||||
PORTAL_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
"https://pink-account.coulomb.social" 2>/dev/null || echo "000")
|
||||
if [[ "$PORTAL_STATUS" == "200" || "$PORTAL_STATUS" == "302" ]]; then
|
||||
pass "Self-service portal reachable (HTTP $PORTAL_STATUS)"
|
||||
elif [[ "$PORTAL_STATUS" == "000" ]]; then
|
||||
warn "Self-service portal not reachable — DNS/TLS/ingress may not be configured yet"
|
||||
else
|
||||
warn "Self-service portal returned HTTP $PORTAL_STATUS (expected 200 or 302)"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " T06 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo " Result: INCOMPLETE — resolve FAIL items before marking T06 done"
|
||||
echo ""
|
||||
echo " Common next steps:"
|
||||
echo " - Run: sso-mfa/k8s/privacyidea/bootstrap-realm.sh"
|
||||
echo " - Run: sso-mfa/k8s/keycape/create-pi-token.sh (then restart keycape)"
|
||||
echo " - Run: sso-mfa/k8s/keycape/create-secrets.sh (to update keycape-config)"
|
||||
exit 1
|
||||
elif [[ "$WARN" -gt 0 ]]; then
|
||||
echo " Result: PARTIAL — T06 core checks pass; review WARN items"
|
||||
echo " Enroll a TOTP token and test the end-to-end login flow."
|
||||
exit 0
|
||||
else
|
||||
echo " Result: COMPLETE — T06 done-criteria met; proceed to T07 (User mgmt & self-service)"
|
||||
exit 0
|
||||
fi
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/privacyidea/verify_mfa.py" "$@"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue