feat(sso-mfa): T05 SSO stack pivot — Keycloak → Authelia + LLDAP + KeyCape (NK-WP-0001-T05)

Replaces the Keycloak+privacyIDEA SSO tier with the lightweight stack built
during KEY-WP-0001: Authelia (password frontend), LLDAP (directory), and
KeyCape (OIDC orchestration). privacyIDEA is retained as the MFA engine.

Stack:
  kc.coulomb.social   — KeyCape OIDC server (stateless, custom Go)
  auth.coulomb.social — Authelia login portal (password auth → Authelia OIDC → KeyCape)
  lldap.coulomb.social — LLDAP admin UI (IP-restricted)
  pink.coulomb.social — privacyIDEA MFA engine (unchanged)

Changes:
- Remove sso-mfa/k8s/keycloak/ (7 files)
- Add sso-mfa/k8s/lldap/ (pvc, deployment, middleware, ingress, create-secrets, README)
- Add sso-mfa/k8s/authelia/ (pvc, configmap, deployment, ingress, create-secrets, README)
- Add sso-mfa/k8s/keycape/ (deployment, middleware, ingress, create-secrets, create-pi-token, README)
- Update network-policies/netpol-sso.yaml for new component topology
- Update verify-t05.sh: checks LLDAP + Authelia + KeyCape (23 checks)
- Update CONFIG.md: fix CP-NK-004 (KeyCape), add CP-NK-005 (Authelia), CP-NK-006 (LLDAP)
- Update bootstrap/gen-secrets.sh: add LLDAP/Authelia/KeyCape sections, remove Keycloak
- Update k8s/README.md: network policy table reflects new traffic paths
- Add sso-mfa/WORKPLAN.md: resumable task checklist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 08:31:51 +00:00
parent d0ed7d9cd6
commit 0754dc32e6
31 changed files with 2098 additions and 1077 deletions

View File

@@ -1,17 +1,32 @@
#!/usr/bin/env bash
# verify-t05.sh — verify NK-WP-0001-T05 done-criteria
#
# Checks:
# 1. Keycloak pod is Running+Ready in the sso namespace
# 2. Keycloak Service exists on port 8080
# 3. Traefik Middlewares exist
# 4. Ingress resources exist with correct hostname
# 5. TLS certificate issued by cert-manager
# 6. Required K8s Secrets are present
# 7. PVC is Bound
# 8. Provider JAR is present inside the pod
# 9. Keycloak health endpoints respond (started, ready)
# 10. net-kingdom realm exists
# Checks all three components of the new SSO stack (LLDAP, Authelia, KeyCape).
#
# Sections:
# 1. LLDAP pod Running+Ready
# 2. LLDAP services (ldap :3890, web-ui :17170)
# 3. LLDAP Middleware (lldap-admin-allowlist)
# 4. LLDAP Ingress + hostname (lldap.coulomb.social)
# 5. LLDAP TLS certificate
# 6. LLDAP Secret (lldap-secrets)
# 7. LLDAP PVC Bound
# 8. LLDAP health endpoint
# 9. Authelia pod Running+Ready
# 10. Authelia service
# 11. Authelia Ingress + hostname (auth.coulomb.social)
# 12. Authelia TLS certificate
# 13. Authelia Secrets (authelia-secrets)
# 14. Authelia PVC Bound
# 15. Authelia health endpoint
# 16. KeyCape pod Running+Ready
# 17. KeyCape service
# 18. KeyCape Middlewares (keycape-rate-limit, keycape-hsts)
# 19. KeyCape Ingress + hostname (kc.coulomb.social)
# 20. KeyCape TLS certificate
# 21. KeyCape Secret (keycape-config)
# 22. KeyCape health endpoint (/healthz)
# 23. KeyCape OIDC discovery (/.well-known/openid-configuration)
#
# Usage:
# chmod +x verify-t05.sh
@@ -20,8 +35,9 @@
set -euo pipefail
NAMESPACE="sso"
KC_HOSTNAME="kc.coulomb.social"
REALM_NAME="net-kingdom"
LLDAP_HOST="lldap.coulomb.social"
AUTH_HOST="auth.coulomb.social"
KC_HOST="kc.coulomb.social"
PASS=0
FAIL=0
WARN=0
@@ -32,55 +48,202 @@ warn() { echo " [WARN] $1"; ((WARN++)); }
section() { echo ""; echo "── $1 ──────────────────────────────────────"; }
# ── 1. Keycloak pod ───────────────────────────────────────────────────────────
section "1. Keycloak pod (namespace: $NAMESPACE)"
KC_POD=$(kubectl get pod -n "$NAMESPACE" \
-l app.kubernetes.io/name=keycloak \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [[ -n "$KC_POD" ]]; then
pass "Pod Running: $KC_POD"
READY=$(kubectl get pod -n "$NAMESPACE" "$KC_POD" \
-o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || echo "false")
if [[ "$READY" == "true" ]]; then
pass "Pod readiness probe passing"
check_pod() {
local name="$1"
local label="$2"
local pod=""
pod=$(kubectl get pod -n "$NAMESPACE" \
-l "app.kubernetes.io/name=${label}" \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [[ -n "$pod" ]]; then
pass "Pod Running: $pod"
local ready
ready=$(kubectl get pod -n "$NAMESPACE" "$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 (probe failing — check logs)"
fi
echo "$pod"
else
fail "Pod is Running but not Ready (probe failing — check logs)"
local count
count=$(kubectl get pod -n "$NAMESPACE" \
-l "app.kubernetes.io/name=${label}" -o name 2>/dev/null | wc -l || echo 0)
if [[ "$count" -gt 0 ]]; then
fail "${name} pod(s) exist but none Running (kubectl describe pod -n $NAMESPACE -l app.kubernetes.io/name=${label})"
else
fail "No ${name} pods found — apply deployment.yaml"
fi
echo ""
fi
}
check_service() {
local svc="$1"; shift
local ports=("$@")
if kubectl get service "$svc" -n "$NAMESPACE" &>/dev/null; then
pass "Service $svc exists"
for port in "${ports[@]}"; do
if kubectl get service "$svc" -n "$NAMESPACE" \
-o jsonpath="{.spec.ports[*].port}" 2>/dev/null | grep -qw "$port"; then
pass "Service $svc has port $port"
else
fail "Service $svc missing port $port"
fi
done
else
fail "Service $svc not found"
fi
}
check_ingress() {
local ing="$1"; local expected_host="$2"
if kubectl get ingress "$ing" -n "$NAMESPACE" &>/dev/null; then
pass "Ingress $ing exists"
local host
host=$(kubectl get ingress "$ing" -n "$NAMESPACE" \
-o jsonpath='{.spec.rules[0].host}' 2>/dev/null || echo "")
if [[ "$host" == "$expected_host" ]]; then
pass "Ingress $ing host: $expected_host"
else
fail "Ingress $ing host is '$host' (expected $expected_host)"
fi
else
fail "Ingress $ing not found — apply ingress.yaml"
fi
}
check_tls() {
local secret="$1"; local host="$2"
if kubectl get secret "$secret" -n "$NAMESPACE" &>/dev/null; then
local cert_name="${secret%-tls}"
local cert_ready
cert_ready=$(kubectl get certificate "$cert_name" -n "$NAMESPACE" \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "")
if [[ "$cert_ready" == "True" ]]; then
pass "Certificate $cert_name Ready (TLS secret $secret issued)"
else
warn "TLS secret $secret exists but cert status not Ready (DNS/ACME pending?)"
fi
else
warn "TLS secret $secret not yet issued (cert-manager pending — check DNS and ACME)"
fi
}
check_secret() {
local secret="$1"
if kubectl get secret "$secret" -n "$NAMESPACE" &>/dev/null; then
pass "Secret $secret exists"
else
fail "Secret $secret not found — run create-secrets.sh"
fi
}
check_pvc() {
local pvc="$1"
local status
status=$(kubectl get pvc "$pvc" -n "$NAMESPACE" \
-o jsonpath='{.status.phase}' 2>/dev/null || echo "not found")
if [[ "$status" == "Bound" ]]; then
pass "PVC $pvc: Bound"
elif [[ "$status" == "not found" ]]; then
fail "PVC $pvc not found — apply pvc.yaml"
else
fail "PVC $pvc status: $status (expected Bound)"
fi
}
# ═══════════════════════════════════════════════════════════════════
# LLDAP
# ═══════════════════════════════════════════════════════════════════
section "1. LLDAP pod (namespace: $NAMESPACE)"
LLDAP_POD=$(check_pod "LLDAP" "lldap")
section "2. LLDAP services"
check_service "lldap" 3890 17170
section "3. LLDAP Middleware"
if kubectl get middleware lldap-admin-allowlist -n "$NAMESPACE" &>/dev/null; then
pass "Middleware lldap-admin-allowlist exists"
else
PENDING=$(kubectl get pod -n "$NAMESPACE" \
-l app.kubernetes.io/name=keycloak \
-o name 2>/dev/null | wc -l || echo 0)
if [[ "$PENDING" -gt 0 ]]; then
fail "Keycloak pod(s) exist but none are Running (check: kubectl describe pod -n $NAMESPACE)"
else
fail "No Keycloak pods found in namespace $NAMESPACE — apply deployment.yaml"
fi
fail "Middleware lldap-admin-allowlist not found — apply middleware.yaml"
fi
# ── 2. Service ────────────────────────────────────────────────────────────────
section "2. Service"
section "4. LLDAP Ingress"
check_ingress "lldap" "$LLDAP_HOST"
if kubectl get service keycloak -n "$NAMESPACE" &>/dev/null; then
pass "Service keycloak exists"
PORT=$(kubectl get service keycloak -n "$NAMESPACE" \
-o jsonpath='{.spec.ports[0].port}' 2>/dev/null || echo "")
if [[ "$PORT" == "8080" ]]; then
pass "Service port: 8080"
section "5. LLDAP TLS certificate"
check_tls "lldap-tls" "$LLDAP_HOST"
section "6. LLDAP Secret"
check_secret "lldap-secrets"
section "7. LLDAP PVC"
check_pvc "lldap-data"
section "8. LLDAP health endpoint"
if [[ -n "$LLDAP_POD" ]]; then
HTTP=$(kubectl exec -n "$NAMESPACE" "$LLDAP_POD" -- \
wget -qO- http://localhost:17170/health 2>/dev/null || echo "")
if echo "$HTTP" | grep -qi "ok\|healthy\|true"; then
pass "LLDAP health endpoint responds OK"
else
warn "Service port is $PORT (expected 8080)"
warn "LLDAP health endpoint response unclear: '$HTTP'"
fi
else
fail "Service keycloak not found in namespace $NAMESPACE"
warn "Skipping LLDAP health check — no running pod"
fi
# ── 3. Traefik Middlewares ────────────────────────────────────────────────────
section "3. Traefik Middlewares"
# ═══════════════════════════════════════════════════════════════════
# Authelia
# ═══════════════════════════════════════════════════════════════════
for mw in keycloak-rate-limit keycloak-admin-allowlist keycloak-hsts; do
section "9. Authelia pod (namespace: $NAMESPACE)"
AUTHELIA_POD=$(check_pod "Authelia" "authelia")
section "10. Authelia service"
check_service "authelia" 9091
section "11. Authelia Ingress"
check_ingress "authelia" "$AUTH_HOST"
section "12. Authelia TLS certificate"
check_tls "auth-tls" "$AUTH_HOST"
section "13. Authelia Secrets"
check_secret "authelia-secrets"
section "14. Authelia PVC"
check_pvc "authelia-data"
section "15. Authelia health endpoint"
if [[ -n "$AUTHELIA_POD" ]]; then
STATUS=$(kubectl exec -n "$NAMESPACE" "$AUTHELIA_POD" -- \
wget -qO- http://localhost:9091/api/health 2>/dev/null || echo "")
if echo "$STATUS" | grep -qi "ok\|healthy"; then
pass "Authelia /api/health responds OK"
else
warn "Authelia /api/health response unclear: '$STATUS'"
fi
else
warn "Skipping Authelia health check — no running pod"
fi
# ═══════════════════════════════════════════════════════════════════
# KeyCape
# ═══════════════════════════════════════════════════════════════════
section "16. KeyCape pod (namespace: $NAMESPACE)"
KC_POD=$(check_pod "KeyCape" "keycape")
section "17. KeyCape service"
check_service "keycape" 8080
section "18. KeyCape Middlewares"
for mw in keycape-rate-limit keycape-hsts; do
if kubectl get middleware "$mw" -n "$NAMESPACE" &>/dev/null; then
pass "Middleware $mw exists"
else
@@ -88,121 +251,51 @@ for mw in keycloak-rate-limit keycloak-admin-allowlist keycloak-hsts; do
fi
done
# ── 4. Ingress resources ──────────────────────────────────────────────────────
section "4. Ingress resources"
section "19. KeyCape Ingress"
check_ingress "keycape" "$KC_HOST"
for ing in keycloak keycloak-admin; do
if kubectl get ingress "$ing" -n "$NAMESPACE" &>/dev/null; then
pass "Ingress $ing exists"
else
fail "Ingress $ing not found — apply ingress.yaml"
fi
done
section "20. KeyCape TLS certificate"
check_tls "kc-tls" "$KC_HOST"
KC_HOST=$(kubectl get ingress keycloak -n "$NAMESPACE" \
-o jsonpath='{.spec.rules[0].host}' 2>/dev/null || echo "")
if [[ "$KC_HOST" == "$KC_HOSTNAME" ]]; then
pass "Ingress host: $KC_HOSTNAME"
else
fail "Ingress host is '$KC_HOST' (expected $KC_HOSTNAME)"
fi
# ── 5. TLS certificate ────────────────────────────────────────────────────────
section "5. TLS certificate"
if kubectl get secret kc-tls -n "$NAMESPACE" &>/dev/null; then
CERT_READY=$(kubectl get certificate kc -n "$NAMESPACE" \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "")
if [[ "$CERT_READY" == "True" ]]; then
pass "Certificate kc is Ready (TLS secret kc-tls exists)"
else
warn "TLS secret kc-tls exists but certificate status is not Ready (DNS propagation pending?)"
fi
else
warn "TLS secret kc-tls not yet issued (cert-manager pending — check DNS and ACME)"
fi
# ── 6. K8s Secrets ────────────────────────────────────────────────────────────
section "6. K8s Secrets (namespace: $NAMESPACE)"
if kubectl get secret keycloak-config -n "$NAMESPACE" &>/dev/null; then
pass "Secret keycloak-config exists"
else
fail "Secret keycloak-config not found — run create-secrets.sh"
fi
# ── 7. PVC ────────────────────────────────────────────────────────────────────
section "7. PersistentVolumeClaim"
STATUS=$(kubectl get pvc keycloak-data -n "$NAMESPACE" \
-o jsonpath='{.status.phase}' 2>/dev/null || echo "not found")
if [[ "$STATUS" == "Bound" ]]; then
pass "PVC keycloak-data: Bound"
elif [[ "$STATUS" == "not found" ]]; then
fail "PVC keycloak-data not found — apply pvc.yaml"
else
fail "PVC keycloak-data status: $STATUS (expected Bound)"
fi
# ── 8. Provider JAR ───────────────────────────────────────────────────────────
section "8. privacyIDEA provider JAR (inside pod)"
section "21. KeyCape Secrets"
check_secret "keycape-config"
section "22. KeyCape health endpoint"
if [[ -n "$KC_POD" ]]; then
if kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
test -f /opt/keycloak/providers/keycloak-provider.jar 2>/dev/null; then
pass "Provider JAR present: /opt/keycloak/providers/keycloak-provider.jar"
STATUS=$(kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
wget -qO- http://localhost:8080/healthz 2>/dev/null || echo "")
if echo "$STATUS" | grep -qi "ok\|healthy"; then
pass "KeyCape /healthz responds OK"
else
fail "Provider JAR not found — check init container logs: kubectl logs -n $NAMESPACE $KC_POD -c install-privacyidea-provider"
fi
# Check that the provider was picked up (appears in the build output directory)
if kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
ls /opt/keycloak/data/generated/ 2>/dev/null | grep -q .; then
pass "Keycloak build cache present (provider build completed)"
else
warn "Build cache empty — Keycloak may still be building (check pod logs)"
warn "KeyCape /healthz response unclear: '$STATUS'"
fi
else
warn "Skipping provider JAR check — no running pod"
warn "Skipping KeyCape health check — no running pod"
fi
# ── 9. Health endpoints ───────────────────────────────────────────────────────
section "9. Keycloak health endpoints (via kubectl exec)"
section "23. KeyCape OIDC discovery"
if [[ -n "$KC_POD" ]]; then
for endpoint in /health/started /health/ready /health/live; do
STATUS_CODE=$(kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
curl -s -o /dev/null -w "%{http_code}" "http://localhost:9000${endpoint}" \
2>/dev/null || echo "")
if [[ "$STATUS_CODE" == "200" ]]; then
pass "GET localhost:9000${endpoint} → 200"
DISCOVERY=$(kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
wget -qO- "http://localhost:8080/.well-known/openid-configuration" 2>/dev/null || echo "")
if echo "$DISCOVERY" | grep -q '"issuer"'; then
pass "OIDC discovery endpoint returns issuer"
ISSUER=$(echo "$DISCOVERY" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print(d.get('issuer',''))" 2>/dev/null || echo "")
if [[ "$ISSUER" == "https://$KC_HOST" ]]; then
pass "OIDC issuer matches CP-NK-004: $ISSUER"
else
fail "GET localhost:9000${endpoint}$STATUS_CODE (expected 200)"
warn "OIDC issuer is '$ISSUER' (expected https://$KC_HOST)"
fi
done
else
warn "Skipping health endpoint checks — no running pod"
fi
# ── 10. Realm exists ──────────────────────────────────────────────────────────
section "10. Keycloak realm: $REALM_NAME"
if [[ -n "$KC_POD" ]]; then
REALM_STATUS=$(kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
curl -s -o /dev/null -w "%{http_code}" \
"http://localhost:8080/realms/${REALM_NAME}" 2>/dev/null || echo "")
if [[ "$REALM_STATUS" == "200" ]]; then
pass "Realm $REALM_NAME exists and responds"
elif [[ "$REALM_STATUS" == "404" ]]; then
warn "Realm $REALM_NAME not found — run bootstrap-realm.sh"
else
warn "Realm $REALM_NAME check returned HTTP $REALM_STATUS"
fail "OIDC discovery endpoint did not return expected JSON"
fi
else
warn "Skipping realm check — no running pod"
warn "Skipping OIDC discovery check — no running pod"
fi
# ── Summary ───────────────────────────────────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════════════
echo ""
echo "════════════════════════════════════════════════════════════"
echo " T05 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL"
@@ -215,6 +308,6 @@ elif [[ "$WARN" -gt 0 ]]; then
echo " Result: PARTIAL — T05 core is up; WARN items should be resolved before T06"
exit 0
else
echo " Result: COMPLETE — T05 done-criteria met; proceed to T06 (realm config & MFA flow)"
echo " Result: COMPLETE — T05 done-criteria met; proceed to T06 (MFA flow integration)"
exit 0
fi