generated from coulomb/repo-seed
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:
68
CONFIG.md
68
CONFIG.md
@@ -23,8 +23,9 @@ If yes to any of the above, don't add it here.
|
||||
| CP-NK-001 | ACME contact email | `bernd.worsch+netkingdom@gmail.com` | `sso-mfa/k8s/cert-manager/issuers.yaml:38` |
|
||||
| CP-NK-002 | privacyIDEA portal hostname | `pink.coulomb.social` | `sso-mfa/k8s/privacyidea/ingress.yaml` |
|
||||
| CP-NK-003 | privacyIDEA self-service hostname | `pink-account.coulomb.social` | `sso-mfa/k8s/privacyidea/ingress.yaml` |
|
||||
| CP-NK-004 | Keycloak SSO hostname | `kc.coulomb.social` | `sso-mfa/k8s/keycloak/deployment.yaml`, `sso-mfa/k8s/keycloak/ingress.yaml` |
|
||||
| CP-NK-005 | privacyIDEA Keycloak Provider JAR URL | *(not set — edit before apply)* | `sso-mfa/k8s/keycloak/deployment.yaml` |
|
||||
| CP-NK-004 | KeyCape OIDC hostname | `kc.coulomb.social` | `sso-mfa/k8s/keycape/ingress.yaml`, `sso-mfa/k8s/authelia/configmap.yaml`, `sso-mfa/k8s/keycape/create-secrets.sh` |
|
||||
| CP-NK-005 | Authelia login portal hostname | `auth.coulomb.social` | `sso-mfa/k8s/authelia/ingress.yaml`, `sso-mfa/k8s/authelia/configmap.yaml` |
|
||||
| CP-NK-006 | LLDAP admin web UI hostname | `lldap.coulomb.social` | `sso-mfa/k8s/lldap/ingress.yaml` |
|
||||
|
||||
---
|
||||
|
||||
@@ -88,45 +89,58 @@ the entire cluster.
|
||||
|
||||
---
|
||||
|
||||
## CP-NK-004 — Keycloak SSO hostname
|
||||
## CP-NK-004 — KeyCape OIDC hostname
|
||||
|
||||
**Value:** `kc.coulomb.social`
|
||||
**Set:** 2026-03-19
|
||||
**Set by:** worsch
|
||||
|
||||
**Location(s):**
|
||||
- `sso-mfa/k8s/keycloak/deployment.yaml` — `KC_HOSTNAME` env var
|
||||
- `sso-mfa/k8s/keycloak/ingress.yaml` — both Ingress `host` fields
|
||||
- `sso-mfa/k8s/keycape/ingress.yaml` — Ingress `host` field
|
||||
- `sso-mfa/k8s/authelia/configmap.yaml` — `redirect_uris` for the KeyCape OIDC client
|
||||
- `sso-mfa/k8s/keycape/create-secrets.sh` — `issuer` and `redirectURI` in config.yaml
|
||||
|
||||
**Why non-default:** Subdomain prefix must be chosen by the operator. `kc` =
|
||||
**K**ey**c**loak, consistent with the service-initial naming pattern.
|
||||
**Why non-default:** Subdomain prefix must be chosen by the operator. `kc` is retained
|
||||
from the original design (`kc` = **K**ey**C**ape) for DNS stability.
|
||||
|
||||
**Scope:** TLS certificate, Traefik routing, Keycloak's internal hostname strictness
|
||||
check, and all OIDC/SAML redirect URIs registered in this realm. Changing this
|
||||
hostname after clients are registered requires updating all registered redirect URIs.
|
||||
**Scope:** TLS certificate, Traefik routing, KeyCape's OIDC issuer claim, and all
|
||||
redirect URIs registered by downstream applications. Changing this hostname after
|
||||
clients are registered requires updating all registered `redirect_uris`.
|
||||
|
||||
---
|
||||
|
||||
## CP-NK-005 — privacyIDEA Keycloak Provider JAR URL
|
||||
## CP-NK-005 — Authelia login portal hostname
|
||||
|
||||
**Value:** *(not set — operator must edit before applying T05)*
|
||||
**Set:** —
|
||||
**Set by:** —
|
||||
**Value:** `auth.coulomb.social`
|
||||
**Set:** 2026-03-19
|
||||
**Set by:** worsch
|
||||
|
||||
**Location(s):**
|
||||
- `sso-mfa/k8s/keycloak/deployment.yaml` — `PROVIDER_JAR_URL` env var in the
|
||||
`install-privacyidea-provider` init container
|
||||
- `sso-mfa/k8s/authelia/ingress.yaml` — Ingress `host` field
|
||||
- `sso-mfa/k8s/authelia/configmap.yaml` — `session.domain` parent domain comment
|
||||
|
||||
**Why non-default:** The JAR URL depends on the chosen release version, which must
|
||||
be verified for compatibility with the deployed Keycloak image version. There is no
|
||||
stable "latest" URL suitable for automation.
|
||||
**Why non-default:** Subdomain prefix must be chosen by the operator. `auth` is the
|
||||
conventional prefix for authentication portals.
|
||||
|
||||
**How to set:**
|
||||
1. Browse https://github.com/privacyIDEA/keycloak-provider/releases
|
||||
2. Choose a release compatible with the Keycloak image version in `deployment.yaml`.
|
||||
3. Edit `deployment.yaml`: replace `EDIT_BEFORE_APPLY` with the `.jar` download URL.
|
||||
4. Update this entry with the chosen URL and version.
|
||||
**Scope:** TLS certificate, Traefik routing, and the Authelia login page that users'
|
||||
browsers are redirected to during the OIDC flow. The session cookie `domain` is set
|
||||
to the parent domain (`coulomb.social`) so the cookie is valid across both
|
||||
`auth.coulomb.social` and `kc.coulomb.social`.
|
||||
|
||||
**Scope:** Keycloak init container only. If switching to a custom Keycloak image
|
||||
(see T05 README "Custom image" section), this config point becomes obsolete and
|
||||
can be removed.
|
||||
---
|
||||
|
||||
## CP-NK-006 — LLDAP admin web UI hostname
|
||||
|
||||
**Value:** `lldap.coulomb.social`
|
||||
**Set:** 2026-03-19
|
||||
**Set by:** worsch
|
||||
|
||||
**Location(s):**
|
||||
- `sso-mfa/k8s/lldap/ingress.yaml` — Ingress `host` field
|
||||
|
||||
**Why non-default:** Subdomain prefix must be chosen by the operator.
|
||||
|
||||
**Scope:** TLS certificate and Traefik routing for the LLDAP admin web UI. Access
|
||||
is IP-restricted by the `lldap-admin-allowlist` Traefik Middleware (VPN/office
|
||||
CIDRs only). The LDAP port (3890) is cluster-internal only and never exposed
|
||||
via Ingress.
|
||||
|
||||
55
sso-mfa/WORKPLAN.md
Normal file
55
sso-mfa/WORKPLAN.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# SSO-MFA Platform — Stack Migration Workplan
|
||||
# NK-WP-0001 — Keycloak → Authelia + LLDAP + KeyCape
|
||||
|
||||
**Updated:** 2026-03-19
|
||||
**Workstream:** sso-mfa-platform (39263c4b-ef70-4053-b782-350834b7e1be)
|
||||
|
||||
## Stack Decision
|
||||
|
||||
Keycloak + privacyIDEA replaced by:
|
||||
- **LLDAP** — lightweight LDAP directory (user store)
|
||||
- **Authelia** — authentication frontend (password auth + OIDC upstream)
|
||||
- **KeyCape** — OIDC orchestration layer (auth code flow + MFA via privacyIDEA adapter)
|
||||
- **privacyIDEA** — MFA engine (unchanged, still in `mfa` namespace)
|
||||
|
||||
Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.coulomb.social (LLDAP admin)
|
||||
|
||||
## Task Status
|
||||
|
||||
| Task | ID (hub) | Status | Notes |
|
||||
|------|----------|--------|-------|
|
||||
| T01 — Vault & secret bootstrap | 7992528c | done | |
|
||||
| T02 — K8s foundations | 721ca6b2 | done | Manifests authored; pending live cluster |
|
||||
| T03 — PostgreSQL | 7fa60004 | done | Manifests authored; pending live cluster |
|
||||
| T04 — privacyIDEA | 6ad1296a | **todo** | Manifests exist in k8s/privacyidea/; pending cluster |
|
||||
| T05 — SSO core (new stack) | b9f73aa6 | **in-progress** | See below |
|
||||
| T06 — Realm config & MFA flow | 3b6379a4 | todo | |
|
||||
| T07 — User mgmt & self-service | c7cf902a | todo | |
|
||||
| T08 — Backups, DR, break-glass | 9cbd1d89 | todo | |
|
||||
|
||||
## T05 — SSO Core (new stack: LLDAP + Authelia + KeyCape)
|
||||
|
||||
### Done
|
||||
- [x] LLDAP manifests: pvc.yaml, deployment.yaml, middleware.yaml, ingress.yaml, create-secrets.sh
|
||||
- [x] Authelia manifests: pvc.yaml, configmap.yaml, deployment.yaml, ingress.yaml, create-secrets.sh
|
||||
- [x] KeyCape manifests: deployment.yaml, middleware.yaml, ingress.yaml, create-secrets.sh
|
||||
- [x] NetworkPolicy: netpol-sso.yaml updated for new components
|
||||
- [x] Keycloak manifests staged for deletion
|
||||
|
||||
### In Progress (this session)
|
||||
- [x] keycape/create-pi-token.sh
|
||||
- [x] lldap/README.md
|
||||
- [x] authelia/README.md
|
||||
- [x] keycape/README.md
|
||||
- [x] Update CONFIG.md (fixed CP-NK-004, removed old CP-NK-005, added CP-NK-005 auth.*, CP-NK-006 lldap.*)
|
||||
- [x] Update bootstrap/gen-secrets.sh (removed Keycloak, added LLDAP/Authelia/KeyCape sections)
|
||||
- [x] Update k8s/README.md (network policy table)
|
||||
- [x] Replace verify-t05.sh (Keycloak → LLDAP+Authelia+KeyCape checks)
|
||||
- [ ] Commit all changes
|
||||
- [ ] Update state hub tasks
|
||||
|
||||
### Done-criteria for T05
|
||||
- All manifests present and consistent
|
||||
- gen-secrets.sh generates correct secrets for new stack
|
||||
- verify-t05.sh checks all three components
|
||||
- Committed to main
|
||||
@@ -33,7 +33,9 @@ rnd_b64() { openssl rand -base64 "$1" | tr -d '\n/+=' | head -c "$2"; }
|
||||
mkdir -p \
|
||||
"$OUT_DIR/privacyidea" \
|
||||
"$OUT_DIR/postgres" \
|
||||
"$OUT_DIR/keycloak" \
|
||||
"$OUT_DIR/lldap" \
|
||||
"$OUT_DIR/authelia" \
|
||||
"$OUT_DIR/keycape" \
|
||||
"$OUT_DIR/breakglass"
|
||||
|
||||
# ── privacyIDEA ────────────────────────────────────────────────────────────────
|
||||
@@ -63,31 +65,78 @@ EOF
|
||||
|
||||
# ── PostgreSQL ─────────────────────────────────────────────────────────────────
|
||||
PG_ROOT_PASS="$(rnd_b64 32 40)"
|
||||
PG_KC_PASS="$(rnd_b64 32 40)"
|
||||
# privacyIDEA DB user reuses PI_DB_PASS (single source of truth)
|
||||
# Note: no keycloak DB user — Keycloak was replaced by the Authelia+LLDAP+KeyCape stack.
|
||||
|
||||
cat > "$OUT_DIR/postgres/secrets.env" <<EOF
|
||||
# PostgreSQL secrets — KeePassXC group: net-kingdom/PostgreSQL
|
||||
# Entry: postgres root → username=postgres password=PG_ROOT_PASSWORD
|
||||
# Entry: keycloak user → username=keycloak password=PG_KEYCLOAK_PASSWORD
|
||||
# Entry: privacyidea user → username=privacyidea password=PI_DB_PASSWORD (copy from privacyIDEA entry)
|
||||
|
||||
PG_ROOT_PASSWORD=$PG_ROOT_PASS
|
||||
PG_KEYCLOAK_PASSWORD=$PG_KC_PASS
|
||||
# PI_DB_PASSWORD is in privacyidea/secrets.env — do NOT copy here; link in KeePassXC
|
||||
EOF
|
||||
|
||||
# ── Keycloak ───────────────────────────────────────────────────────────────────
|
||||
KC_ADMIN_PASS="$(rnd_b64 32 40)"
|
||||
# Keycloak DB password == PG_KEYCLOAK_PASSWORD (single source of truth)
|
||||
# ── LLDAP ──────────────────────────────────────────────────────────────────────
|
||||
LLDAP_JWT_SECRET="$(rnd_hex 32)" # 64 hex chars — LLDAP JWT signing key
|
||||
LLDAP_LDAP_USER_PASS="$(rnd_b64 32 40)" # 40 printable chars — LLDAP admin + Authelia/KeyCape bind password
|
||||
|
||||
cat > "$OUT_DIR/keycloak/secrets.env" <<EOF
|
||||
# Keycloak secrets — KeePassXC group: net-kingdom/Keycloak
|
||||
# Entry: admin → username=admin password=KC_ADMIN_PASSWORD
|
||||
# Entry: database → username=keycloak password=KC_DB_PASSWORD (copy from postgres/keycloak user)
|
||||
cat > "$OUT_DIR/lldap/secrets.env" <<EOF
|
||||
# LLDAP secrets — KeePassXC group: net-kingdom/LLDAP
|
||||
# Entry: jwt-secret → password=LLDAP_JWT_SECRET (no username)
|
||||
# Entry: admin → username=admin password=LLDAP_LDAP_USER_PASS
|
||||
#
|
||||
# LLDAP_LDAP_USER_PASS is also the LDAP bind password for Authelia and KeyCape.
|
||||
# It is read from this file by both authelia/create-secrets.sh and keycape/create-secrets.sh.
|
||||
|
||||
KC_ADMIN_PASSWORD=$KC_ADMIN_PASS
|
||||
KC_DB_PASSWORD=$PG_KC_PASS
|
||||
LLDAP_JWT_SECRET=$LLDAP_JWT_SECRET
|
||||
LLDAP_LDAP_USER_PASS=$LLDAP_LDAP_USER_PASS
|
||||
EOF
|
||||
|
||||
# ── Authelia ───────────────────────────────────────────────────────────────────
|
||||
AUTHELIA_JWT_SECRET="$(rnd_hex 32)" # 64 hex chars — session JWT signing
|
||||
AUTHELIA_SESSION_SECRET="$(rnd_hex 32)" # 64 hex chars — session cookie encryption
|
||||
AUTHELIA_STORAGE_ENCRYPTION_KEY="$(rnd_b64 48 64)" # 64 printable chars — SQLite encryption
|
||||
AUTHELIA_OIDC_HMAC_SECRET="$(rnd_hex 32)" # 64 hex chars — OIDC HMAC
|
||||
AUTHELIA_KEYCAPE_CLIENT_SECRET="$(rnd_b64 32 40)" # 40 printable chars — Authelia→KeyCape OIDC client secret
|
||||
# OIDC issuer private key: generated by authelia/create-secrets.sh on first run,
|
||||
# or set AUTHELIA_OIDC_PRIVATE_KEY_FILE to a path below and generate with:
|
||||
# openssl genrsa -out secrets/authelia/oidc_private_key.pem 2048
|
||||
|
||||
cat > "$OUT_DIR/authelia/secrets.env" <<EOF
|
||||
# Authelia secrets — KeePassXC group: net-kingdom/Authelia
|
||||
# Entry: jwt-secret → password=AUTHELIA_JWT_SECRET (no username)
|
||||
# Entry: session-secret → password=AUTHELIA_SESSION_SECRET (no username)
|
||||
# Entry: storage-encryption-key → password=AUTHELIA_STORAGE_ENCRYPTION_KEY (no username)
|
||||
# Entry: oidc-hmac-secret → password=AUTHELIA_OIDC_HMAC_SECRET (no username)
|
||||
# Entry: keycape-client-secret → password=AUTHELIA_KEYCAPE_CLIENT_SECRET (no username)
|
||||
# Entry: oidc-private-key → binary attachment oidc_private_key.pem
|
||||
#
|
||||
# AUTHELIA_KEYCAPE_CLIENT_SECRET is the plaintext — authelia/create-secrets.sh hashes
|
||||
# it with bcrypt before storing in the K8s Secret.
|
||||
# The same plaintext goes into KeyCape's config as authelia.clientSecret.
|
||||
|
||||
AUTHELIA_JWT_SECRET=$AUTHELIA_JWT_SECRET
|
||||
AUTHELIA_SESSION_SECRET=$AUTHELIA_SESSION_SECRET
|
||||
AUTHELIA_STORAGE_ENCRYPTION_KEY=$AUTHELIA_STORAGE_ENCRYPTION_KEY
|
||||
AUTHELIA_OIDC_HMAC_SECRET=$AUTHELIA_OIDC_HMAC_SECRET
|
||||
AUTHELIA_KEYCAPE_CLIENT_SECRET=$AUTHELIA_KEYCAPE_CLIENT_SECRET
|
||||
# AUTHELIA_OIDC_PRIVATE_KEY_FILE= # leave blank — authelia/create-secrets.sh will generate
|
||||
EOF
|
||||
|
||||
# ── KeyCape ────────────────────────────────────────────────────────────────────
|
||||
# KeyCape has no generated secrets here:
|
||||
# key.pem — RSA signing key, generated by keycape/create-secrets.sh on first run
|
||||
# pi_admin_token — generated by keycape/create-pi-token.sh AFTER privacyIDEA is bootstrapped
|
||||
# We create the directory and a placeholder file so operators know to check it.
|
||||
|
||||
cat > "$OUT_DIR/keycape/secrets.env" <<EOF
|
||||
# KeyCape secrets — KeePassXC group: net-kingdom/KeyCape
|
||||
# Entry: jwt-signing-key → binary attachment key.pem (generated by keycape/create-secrets.sh)
|
||||
# Entry: pi-admin-token → password=PI_ADMIN_TOKEN (generated by keycape/create-pi-token.sh)
|
||||
#
|
||||
# This file is a placeholder. No values to set here manually.
|
||||
# Both secrets are generated by the respective create-*.sh scripts.
|
||||
EOF
|
||||
|
||||
# ── Break-glass ────────────────────────────────────────────────────────────────
|
||||
@@ -107,20 +156,31 @@ echo "Generated: $(date -Iseconds)"
|
||||
echo "Output : $OUT_DIR/"
|
||||
echo ""
|
||||
echo " privacyIDEA:"
|
||||
echo " PI_SECRET_KEY : $(wc -c < "$OUT_DIR/privacyidea/secrets.env" > /dev/null; echo "${PI_SECRET_KEY:0:16}…")"
|
||||
echo " PI_SECRET_KEY : ${PI_SECRET_KEY:0:16}…"
|
||||
echo " PI_PEPPER : ${PI_PEPPER:0:8}…"
|
||||
echo " PI_DB_PASSWORD : ${PI_DB_PASS:0:8}…"
|
||||
echo " PI_ADMIN_PASSWORD: ${PI_ADMIN_PASS:0:8}…"
|
||||
echo " PI_ENCFILE : *** generate after container deploy (see comments) ***"
|
||||
echo ""
|
||||
echo " PostgreSQL:"
|
||||
echo " PG_ROOT_PASSWORD : ${PG_ROOT_PASS:0:8}…"
|
||||
echo " PG_KEYCLOAK_PASSWORD: ${PG_KC_PASS:0:8}…"
|
||||
echo " PG_PI_PASSWORD : (same as PI_DB_PASSWORD)"
|
||||
echo " PG_ROOT_PASSWORD : ${PG_ROOT_PASS:0:8}…"
|
||||
echo " PG_PI_PASSWORD : (same as PI_DB_PASSWORD)"
|
||||
echo ""
|
||||
echo " Keycloak:"
|
||||
echo " KC_ADMIN_PASSWORD: ${KC_ADMIN_PASS:0:8}…"
|
||||
echo " KC_DB_PASSWORD : (same as PG_KEYCLOAK_PASSWORD)"
|
||||
echo " LLDAP:"
|
||||
echo " LLDAP_JWT_SECRET : ${LLDAP_JWT_SECRET:0:8}…"
|
||||
echo " LLDAP_LDAP_USER_PASS: ${LLDAP_LDAP_USER_PASS:0:8}…"
|
||||
echo ""
|
||||
echo " Authelia:"
|
||||
echo " AUTHELIA_JWT_SECRET : ${AUTHELIA_JWT_SECRET:0:8}…"
|
||||
echo " AUTHELIA_SESSION_SECRET : ${AUTHELIA_SESSION_SECRET:0:8}…"
|
||||
echo " AUTHELIA_STORAGE_ENCRYPTION_KEY: ${AUTHELIA_STORAGE_ENCRYPTION_KEY:0:8}…"
|
||||
echo " AUTHELIA_OIDC_HMAC_SECRET : ${AUTHELIA_OIDC_HMAC_SECRET:0:8}…"
|
||||
echo " AUTHELIA_KEYCAPE_CLIENT_SECRET : ${AUTHELIA_KEYCAPE_CLIENT_SECRET:0:8}…"
|
||||
echo " AUTHELIA_OIDC_PRIVATE_KEY : *** generated by authelia/create-secrets.sh ***"
|
||||
echo ""
|
||||
echo " KeyCape:"
|
||||
echo " key.pem : *** generated by keycape/create-secrets.sh ***"
|
||||
echo " pi_admin_token : *** generated by keycape/create-pi-token.sh (after T04 bootstrap) ***"
|
||||
echo ""
|
||||
echo " Break-glass:"
|
||||
echo " BREAKGLASS_PASSWORD: ${BG_PASS:0:8}…"
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
Phase 1 of NK-WP-0001: namespaces, NetworkPolicies, cert-manager, StorageClass.
|
||||
|
||||
## SSO stack overview
|
||||
|
||||
The `sso` namespace hosts three components:
|
||||
- **KeyCape** (`kc.coulomb.social`) — OIDC orchestration layer, stateless
|
||||
- **Authelia** (`auth.coulomb.social`) — password authentication frontend
|
||||
- **LLDAP** (`lldap.coulomb.social`) — lightweight LDAP directory (admin UI restricted)
|
||||
|
||||
The `mfa` namespace hosts:
|
||||
- **privacyIDEA** (`pink.coulomb.social`) — MFA engine, called by KeyCape
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- K3s cluster running (ThreePhoenix HA or single-node dev)
|
||||
@@ -58,10 +68,14 @@ required paths are opened:
|
||||
|
||||
| Source | Destination | Port | Purpose |
|
||||
|--------|-------------|------|---------|
|
||||
| Traefik (kube-system) | Keycloak (sso) | 8080 | OIDC/SAML ingress |
|
||||
| Traefik (kube-system) | privacyIDEA (mfa) | 8080 | MFA portal ingress |
|
||||
| Keycloak (sso) | privacyIDEA (mfa) | 8080 | Provider API calls |
|
||||
| Keycloak (sso) | PostgreSQL (databases) | 5432 | DB |
|
||||
| Traefik (kube-system) | KeyCape (sso) | 8080 | OIDC endpoints — public |
|
||||
| Traefik (kube-system) | Authelia (sso) | 9091 | Login portal — public |
|
||||
| Traefik (kube-system) | LLDAP (sso) | 17170 | Admin web UI — IP-restricted |
|
||||
| Traefik (kube-system) | privacyIDEA (mfa) | 8080 | MFA portal — public |
|
||||
| KeyCape (sso) | Authelia (sso) | 9091 | OIDC token exchange |
|
||||
| KeyCape (sso) | LLDAP (sso) | 3890 | User attribute lookup |
|
||||
| KeyCape (sso) | privacyIDEA (mfa) | 8080 | MFA challenge + validation |
|
||||
| Authelia (sso) | LLDAP (sso) | 3890 | Credential validation |
|
||||
| privacyIDEA (mfa) | PostgreSQL (databases) | 5432 | DB |
|
||||
| CNPG operator (cnpg-system) | PostgreSQL (databases) | 5432/9187 | Operator + metrics |
|
||||
| All pods | kube-dns (kube-system) | 53 | DNS resolution |
|
||||
@@ -72,17 +86,18 @@ required paths are opened:
|
||||
After applying NetworkPolicies, confirm that illegal paths are blocked:
|
||||
|
||||
```bash
|
||||
# Test: Keycloak → databases direct (should be ALLOWED)
|
||||
# Test: KeyCape → privacyIDEA (should be ALLOWED)
|
||||
kubectl run test-allowed -n sso --rm -it --image=busybox --restart=Never \
|
||||
-- nc -zv net-kingdom-pg-rw.databases.svc.cluster.local 5432
|
||||
-- nc -zv privacyidea.mfa.svc.cluster.local 8080
|
||||
|
||||
# Test: mfa → sso (should be DENIED — privacyIDEA must not reach Keycloak directly)
|
||||
kubectl run test-denied -n mfa --rm -it --image=busybox --restart=Never \
|
||||
-- nc -zv -w3 keycloak.sso.svc.cluster.local 8080
|
||||
# Test: Authelia → privacyIDEA (should be DENIED — only KeyCape calls privacyIDEA)
|
||||
kubectl run test-denied -n sso --rm -it --image=busybox --restart=Never \
|
||||
-l app.kubernetes.io/name=authelia \
|
||||
-- nc -zv -w3 privacyidea.mfa.svc.cluster.local 8080
|
||||
|
||||
# Test: databases → sso (should be DENIED — DB pods must not initiate connections)
|
||||
kubectl run test-denied2 -n databases --rm -it --image=busybox --restart=Never \
|
||||
-- nc -zw3 keycloak.sso.svc.cluster.local 8080
|
||||
-- nc -zw3 keycape.sso.svc.cluster.local 8080
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
85
sso-mfa/k8s/authelia/README.md
Normal file
85
sso-mfa/k8s/authelia/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# T05b — Authelia (Authentication Frontend)
|
||||
|
||||
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
|
||||
an authorization code to KeyCape, which then performs the MFA step via privacyIDEA.
|
||||
|
||||
**Important:** Authelia's access control policy is set to `one_factor` (password only).
|
||||
MFA is handled exclusively by KeyCape + privacyIDEA. Do not change this to `two_factor`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- T05a complete (LLDAP is Running and healthy, application groups created)
|
||||
- `bootstrap/gen-secrets.sh` run and `secrets/authelia/secrets.env` populated in KeePassXC
|
||||
- `kubectl` configured with cluster access
|
||||
|
||||
## Apply order
|
||||
|
||||
```bash
|
||||
# 1. Create K8s Secret
|
||||
cd sso-mfa/k8s/authelia
|
||||
chmod +x create-secrets.sh
|
||||
./create-secrets.sh
|
||||
|
||||
# 2. Apply manifests (order matters)
|
||||
kubectl apply -f pvc.yaml
|
||||
kubectl apply -f configmap.yaml
|
||||
kubectl apply -f deployment.yaml
|
||||
kubectl apply -f ingress.yaml
|
||||
|
||||
# 3. Wait for pod to be ready
|
||||
# The startup probe allows 90 s for the initial LLDAP connection.
|
||||
kubectl rollout status deployment/authelia -n sso --timeout=120s
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All non-sensitive configuration is in `configmap.yaml` (mounted as `configuration.yml`).
|
||||
Sensitive values are injected via `*_FILE` environment variables pointing to
|
||||
Secret-mounted files (see `deployment.yaml` env section).
|
||||
|
||||
Key config points:
|
||||
- `authentication_backend.ldap.url` — points to LLDAP cluster-internal service
|
||||
- `identity_providers.oidc.clients[0].redirect_uris` — must match CP-NK-004 (`kc.coulomb.social`)
|
||||
- `session.domain` — set to parent domain `coulomb.social` so cookies are valid across
|
||||
both `auth.coulomb.social` and `kc.coulomb.social`
|
||||
|
||||
## Secrets managed
|
||||
|
||||
| Secret name | Keys | Purpose |
|
||||
|-------------|------|---------|
|
||||
| `authelia-secrets` | `jwt_secret` | Session JWT signing |
|
||||
| | `session_secret` | Session cookie encryption |
|
||||
| | `storage_encryption_key` | SQLite database encryption |
|
||||
| | `ldap_password` | LDAP bind password (= `LLDAP_LDAP_USER_PASS`) |
|
||||
| | `oidc_hmac_secret` | OIDC HMAC signing |
|
||||
| | `oidc_issuer_private_key` | RSA-2048 private key for OIDC token signing |
|
||||
| | `keycape_client_secret_hash` | Bcrypt hash of `AUTHELIA_KEYCAPE_CLIENT_SECRET` |
|
||||
|
||||
`create-secrets.sh` reads plaintext values from `secrets/authelia/secrets.env` and
|
||||
`secrets/lldap/secrets.env`. It generates the bcrypt hash on the fly (requires
|
||||
`python3+bcrypt` or `apache2-utils`). The RSA OIDC private key is generated
|
||||
automatically if `AUTHELIA_OIDC_PRIVATE_KEY_FILE` is not set.
|
||||
|
||||
## Storage
|
||||
|
||||
`authelia-data` PVC (1 Gi, ReadWriteOnce) holds:
|
||||
- `db.sqlite3` — SQLite database (user sessions, regulation data)
|
||||
- `notification.txt` — notification log (filesystem notifier)
|
||||
|
||||
Back this PVC up alongside the LLDAP PVC.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# Pod status
|
||||
kubectl get pod -n sso -l app.kubernetes.io/name=authelia
|
||||
|
||||
# Health check
|
||||
kubectl run -n sso --rm -it auth-test --image=busybox --restart=Never \
|
||||
-- wget -qO- http://authelia.sso.svc.cluster.local:9091/api/health
|
||||
|
||||
# OIDC discovery (should return issuer + endpoints)
|
||||
curl -s https://auth.coulomb.social/.well-known/openid-configuration | jq .
|
||||
```
|
||||
120
sso-mfa/k8s/authelia/configmap.yaml
Normal file
120
sso-mfa/k8s/authelia/configmap.yaml
Normal file
@@ -0,0 +1,120 @@
|
||||
# ConfigMap — Authelia configuration (namespace: sso)
|
||||
#
|
||||
# Contains the full Authelia configuration.yml EXCEPT sensitive values,
|
||||
# which are injected at runtime via environment variables from authelia-secrets:
|
||||
#
|
||||
# AUTHELIA_JWT_SECRET_FILE
|
||||
# AUTHELIA_SESSION_SECRET_FILE
|
||||
# AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
|
||||
# AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
|
||||
# AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
|
||||
# AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
|
||||
# AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS_0_SECRET_FILE
|
||||
#
|
||||
# The *_FILE convention tells Authelia to read the secret from a file path
|
||||
# (mounted from the authelia-secrets K8s Secret — see deployment.yaml).
|
||||
#
|
||||
# Access control policy is deliberately set to one_factor (password only).
|
||||
# MFA is handled out-of-band by KeyCape via the privacyIDEA adapter AFTER
|
||||
# Authelia confirms the user's password. Authelia must NOT prompt for a
|
||||
# second factor; doing so would double-challenge the user.
|
||||
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: authelia-config
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: authelia
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
data:
|
||||
configuration.yml: |
|
||||
---
|
||||
theme: dark
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 9091
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
# jwt_secret: injected via AUTHELIA_JWT_SECRET_FILE
|
||||
|
||||
authentication_backend:
|
||||
ldap:
|
||||
# LLDAP preset configures the correct attributes for lldap/lldap image.
|
||||
implementation: lldap
|
||||
url: ldap://lldap.sso.svc.cluster.local:3890
|
||||
base_dn: dc=netkingdom,dc=local
|
||||
username_attribute: uid
|
||||
additional_users_dn: ou=people
|
||||
users_filter: "(&(uid={input})(objectClass=inetOrgPerson))"
|
||||
additional_groups_dn: ou=groups
|
||||
groups_filter: "(member={dn})"
|
||||
group_name_attribute: cn
|
||||
mail_attribute: mail
|
||||
display_name_attribute: displayName
|
||||
user: uid=admin,ou=people,dc=netkingdom,dc=local
|
||||
# password: injected via AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
|
||||
|
||||
session:
|
||||
name: authelia_session
|
||||
# secret: injected via AUTHELIA_SESSION_SECRET_FILE
|
||||
expiration: 1h
|
||||
inactivity: 15m
|
||||
# domain must cover both auth.coulomb.social and kc.coulomb.social
|
||||
# so the session cookie is valid across the SSO flow redirect.
|
||||
domain: coulomb.social # CP-NK — parent domain; update if hostname domain changes
|
||||
|
||||
regulation:
|
||||
max_retries: 5
|
||||
find_time: 2m
|
||||
ban_time: 10m
|
||||
|
||||
storage:
|
||||
# encryption_key: injected via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
|
||||
local:
|
||||
path: /var/authelia/data/db.sqlite3
|
||||
|
||||
notifier:
|
||||
disable_startup_check: true
|
||||
filesystem:
|
||||
filename: /var/authelia/data/notification.txt
|
||||
|
||||
# ── Access control ────────────────────────────────────────────────────────
|
||||
# one_factor = password only. MFA is handled by KeyCape + privacyIDEA.
|
||||
# Do NOT change to two_factor here.
|
||||
access_control:
|
||||
default_policy: one_factor
|
||||
|
||||
# ── OIDC identity provider ────────────────────────────────────────────────
|
||||
# Authelia acts as an upstream OIDC provider for KeyCape.
|
||||
# KeyCape is the only registered client.
|
||||
identity_providers:
|
||||
oidc:
|
||||
# hmac_secret: injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
|
||||
# issuer_private_key: injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
|
||||
clients:
|
||||
- id: keycape
|
||||
description: "KeyCape IAM Orchestration Layer"
|
||||
# secret (bcrypt hash): injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS_0_SECRET_FILE
|
||||
public: false
|
||||
authorization_policy: one_factor
|
||||
consent_mode: implicit
|
||||
redirect_uris:
|
||||
# CP-NK-004 — update if kc.coulomb.social hostname changes
|
||||
- "https://kc.coulomb.social/authorize/callback"
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
- groups
|
||||
grant_types:
|
||||
- authorization_code
|
||||
response_types:
|
||||
- code
|
||||
response_modes:
|
||||
- query
|
||||
userinfo_signing_algorithm: none
|
||||
113
sso-mfa/k8s/authelia/create-secrets.sh
Normal file
113
sso-mfa/k8s/authelia/create-secrets.sh
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-secrets.sh — create the authelia-secrets K8s Secret
|
||||
#
|
||||
# Usage:
|
||||
# ./create-secrets.sh [secrets-dir]
|
||||
#
|
||||
# <secrets-dir> is the output directory from sso-mfa/bootstrap/gen-secrets.sh
|
||||
# (default: ../../bootstrap/secrets).
|
||||
#
|
||||
# Creates ONE Secret in the sso namespace:
|
||||
# authelia-secrets — all Authelia sensitive values as named files:
|
||||
# jwt_secret ← AUTHELIA_JWT_SECRET_FILE
|
||||
# session_secret ← AUTHELIA_SESSION_SECRET_FILE
|
||||
# storage_encryption_key ← AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
|
||||
# ldap_password ← AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
|
||||
# oidc_hmac_secret ← AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
|
||||
# oidc_issuer_private_key ← AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
|
||||
# keycape_client_secret_hash ← AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS_0_SECRET_FILE
|
||||
#
|
||||
# The keycape_client_secret_hash is the bcrypt hash of AUTHELIA_KEYCAPE_CLIENT_SECRET
|
||||
# from secrets/authelia/secrets.env. The plaintext is stored in KeyCape's secret.
|
||||
#
|
||||
# Requires: kubectl, openssl, python3 (for bcrypt hash generation)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_DIR="${1:-../../bootstrap/secrets}"
|
||||
AUTHELIA_ENV="$SECRETS_DIR/authelia/secrets.env"
|
||||
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
|
||||
|
||||
for f in "$AUTHELIA_ENV" "$LLDAP_ENV"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "ERROR: $f not found" >&2
|
||||
echo "Run sso-mfa/bootstrap/gen-secrets.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
|
||||
|
||||
AUTHELIA_JWT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_JWT_SECRET)
|
||||
AUTHELIA_SESSION_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_SESSION_SECRET)
|
||||
AUTHELIA_STORAGE_ENCRYPTION_KEY=$(read_env "$AUTHELIA_ENV" AUTHELIA_STORAGE_ENCRYPTION_KEY)
|
||||
AUTHELIA_OIDC_HMAC_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_OIDC_HMAC_SECRET)
|
||||
AUTHELIA_OIDC_PRIVATE_KEY=$(read_env "$AUTHELIA_ENV" AUTHELIA_OIDC_PRIVATE_KEY_FILE)
|
||||
AUTHELIA_KEYCAPE_CLIENT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_KEYCAPE_CLIENT_SECRET)
|
||||
LLDAP_LDAP_USER_PASS=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS)
|
||||
|
||||
# Validate required values
|
||||
REQUIRED=(AUTHELIA_JWT_SECRET AUTHELIA_SESSION_SECRET AUTHELIA_STORAGE_ENCRYPTION_KEY
|
||||
AUTHELIA_OIDC_HMAC_SECRET AUTHELIA_KEYCAPE_CLIENT_SECRET LLDAP_LDAP_USER_PASS)
|
||||
for var in "${REQUIRED[@]}"; do
|
||||
if [[ -z "${!var}" ]]; then
|
||||
echo "ERROR: $var is empty — re-run gen-secrets.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# The OIDC issuer private key is stored as a file path in secrets.env.
|
||||
# Read the actual PEM content from the path referenced there.
|
||||
AUTHELIA_OIDC_PRIVATE_KEY_PATH=$(read_env "$AUTHELIA_ENV" AUTHELIA_OIDC_PRIVATE_KEY_FILE)
|
||||
if [[ -z "$AUTHELIA_OIDC_PRIVATE_KEY_PATH" ]]; then
|
||||
# Fall back: generate a new RSA key on the fly (dev only)
|
||||
echo "WARN: AUTHELIA_OIDC_PRIVATE_KEY_FILE not set — generating a new RSA-2048 key."
|
||||
echo " Store the generated key in KeePassXC as net-kingdom/Authelia/oidc-private-key."
|
||||
TMP_KEY=$(mktemp)
|
||||
openssl genrsa -out "$TMP_KEY" 2048 2>/dev/null
|
||||
AUTHELIA_OIDC_PRIVATE_KEY_CONTENT=$(cat "$TMP_KEY")
|
||||
shred -u "$TMP_KEY"
|
||||
elif [[ -f "$AUTHELIA_OIDC_PRIVATE_KEY_PATH" ]]; then
|
||||
AUTHELIA_OIDC_PRIVATE_KEY_CONTENT=$(cat "$AUTHELIA_OIDC_PRIVATE_KEY_PATH")
|
||||
else
|
||||
echo "ERROR: OIDC private key file not found: $AUTHELIA_OIDC_PRIVATE_KEY_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Hash the Authelia-KeyCape client secret with bcrypt (cost 12).
|
||||
# Authelia requires the bcrypt hash for OIDC client secrets.
|
||||
echo "Hashing KeyCape client secret with bcrypt (this may take a moment)..."
|
||||
if command -v python3 &>/dev/null && python3 -c "import bcrypt" 2>/dev/null; then
|
||||
KEYCAPE_CLIENT_SECRET_HASH=$(python3 -c "
|
||||
import bcrypt, sys
|
||||
pw = sys.argv[1].encode()
|
||||
print(bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12)).decode())
|
||||
" "$AUTHELIA_KEYCAPE_CLIENT_SECRET")
|
||||
elif command -v htpasswd &>/dev/null; then
|
||||
KEYCAPE_CLIENT_SECRET_HASH=$(htpasswd -nbBC 12 "" "$AUTHELIA_KEYCAPE_CLIENT_SECRET" | cut -d: -f2)
|
||||
else
|
||||
echo "ERROR: bcrypt hash generation requires python3+bcrypt or apache2-utils (htpasswd)" >&2
|
||||
echo " Install: pip3 install bcrypt OR apt install apache2-utils" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating K8s Secret: authelia-secrets (namespace: sso)"
|
||||
kubectl create secret generic authelia-secrets \
|
||||
--namespace=sso \
|
||||
--from-literal=jwt_secret="$AUTHELIA_JWT_SECRET" \
|
||||
--from-literal=session_secret="$AUTHELIA_SESSION_SECRET" \
|
||||
--from-literal=storage_encryption_key="$AUTHELIA_STORAGE_ENCRYPTION_KEY" \
|
||||
--from-literal=ldap_password="$LLDAP_LDAP_USER_PASS" \
|
||||
--from-literal=oidc_hmac_secret="$AUTHELIA_OIDC_HMAC_SECRET" \
|
||||
--from-literal=oidc_issuer_private_key="$AUTHELIA_OIDC_PRIVATE_KEY_CONTENT" \
|
||||
--from-literal=keycape_client_secret_hash="$KEYCAPE_CLIENT_SECRET_HASH" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
echo ""
|
||||
echo "Done. Secret authelia-secrets created in namespace: sso"
|
||||
echo ""
|
||||
echo "Next:"
|
||||
echo " Apply deployment.yaml, then ingress.yaml."
|
||||
echo " The plaintext Authelia-KeyCape client secret is in secrets/authelia/secrets.env"
|
||||
echo " as AUTHELIA_KEYCAPE_CLIENT_SECRET. This value goes into keycape/create-secrets.sh"
|
||||
echo " as the authelia.clientSecret in the KeyCape config."
|
||||
147
sso-mfa/k8s/authelia/deployment.yaml
Normal file
147
sso-mfa/k8s/authelia/deployment.yaml
Normal file
@@ -0,0 +1,147 @@
|
||||
# Deployment + Service — Authelia (namespace: sso)
|
||||
#
|
||||
# Authelia is the authentication frontend: it handles username/password entry
|
||||
# and redirects back to KeyCape with an authorization code. KeyCape then
|
||||
# invokes the privacyIDEA adapter to perform the MFA step.
|
||||
#
|
||||
# Prerequisites (apply in order):
|
||||
# 1. pvc.yaml — authelia-data PVC
|
||||
# 2. configmap.yaml — authelia-config ConfigMap
|
||||
# 3. create-secrets.sh — authelia-secrets (JWT, session, storage, LDAP, OIDC keys)
|
||||
# 4. This file
|
||||
# 5. ingress.yaml
|
||||
#
|
||||
# Sensitive values are passed as *_FILE env vars pointing to Secret-mounted files.
|
||||
# See configmap.yaml for the full list of injected secrets.
|
||||
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: authelia
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: authelia
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: authelia
|
||||
strategy:
|
||||
type: Recreate # single replica; SQLite cannot be accessed concurrently
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: authelia
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 8000 # authelia default user
|
||||
fsGroup: 8000
|
||||
|
||||
containers:
|
||||
- name: authelia
|
||||
# Pin to a specific 4.x release. Check https://hub.docker.com/r/authelia/authelia
|
||||
image: authelia/authelia:4.38
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 9091
|
||||
protocol: TCP
|
||||
|
||||
# ── Secret file paths — Authelia reads *_FILE env vars ──────────
|
||||
env:
|
||||
- name: AUTHELIA_JWT_SECRET_FILE
|
||||
value: /run/secrets/authelia/jwt_secret
|
||||
- name: AUTHELIA_SESSION_SECRET_FILE
|
||||
value: /run/secrets/authelia/session_secret
|
||||
- name: AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
|
||||
value: /run/secrets/authelia/storage_encryption_key
|
||||
- name: AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
|
||||
value: /run/secrets/authelia/ldap_password
|
||||
- name: AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
|
||||
value: /run/secrets/authelia/oidc_hmac_secret
|
||||
- name: AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
|
||||
value: /run/secrets/authelia/oidc_issuer_private_key
|
||||
- name: AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS_0_SECRET_FILE
|
||||
value: /run/secrets/authelia/keycape_client_secret_hash
|
||||
|
||||
volumeMounts:
|
||||
# Config from ConfigMap
|
||||
- name: config
|
||||
mountPath: /config/configuration.yml
|
||||
subPath: configuration.yml
|
||||
readOnly: true
|
||||
# Secrets as files
|
||||
- name: secrets
|
||||
mountPath: /run/secrets/authelia
|
||||
readOnly: true
|
||||
# Writable data (SQLite DB + notification log)
|
||||
- name: data
|
||||
mountPath: /var/authelia/data
|
||||
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 9091
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
failureThreshold: 18 # 18 × 5s = 90s for initial LDAP connection
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 9091
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 9091
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "50m"
|
||||
memory: "128Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "256Mi"
|
||||
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: authelia-config
|
||||
- name: secrets
|
||||
secret:
|
||||
secretName: authelia-secrets
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: authelia-data
|
||||
|
||||
---
|
||||
# Service — ClusterIP; Traefik and KeyCape reach Authelia via port 9091.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: authelia
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: authelia
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: authelia
|
||||
ports:
|
||||
- name: http
|
||||
port: 9091
|
||||
targetPort: 9091
|
||||
protocol: TCP
|
||||
39
sso-mfa/k8s/authelia/ingress.yaml
Normal file
39
sso-mfa/k8s/authelia/ingress.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Ingress — Authelia login portal (namespace: sso)
|
||||
#
|
||||
# auth.coulomb.social — Authelia login page; browsers are redirected here
|
||||
# by KeyCape during the OIDC authorization flow.
|
||||
#
|
||||
# This hostname MUST be publicly reachable: users' browsers redirect here
|
||||
# to enter their password. (MFA happens at the KeyCape layer, not here.)
|
||||
#
|
||||
# Config points (see CONFIG.md):
|
||||
# CP-NK-005 auth.coulomb.social
|
||||
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: authelia
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: authelia
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: auth.coulomb.social
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: authelia
|
||||
port:
|
||||
number: 9091
|
||||
tls:
|
||||
- secretName: auth-tls
|
||||
hosts:
|
||||
- auth.coulomb.social
|
||||
20
sso-mfa/k8s/authelia/pvc.yaml
Normal file
20
sso-mfa/k8s/authelia/pvc.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# PersistentVolumeClaim for Authelia (namespace: sso)
|
||||
#
|
||||
# authelia-data — /var/authelia/data/
|
||||
# Holds: SQLite database (user sessions, TOTP registrations if used,
|
||||
# webauthn data) and notification log.
|
||||
# All authentication state is here; back this PVC up alongside LLDAP's.
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: authelia-data
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
145
sso-mfa/k8s/keycape/README.md
Normal file
145
sso-mfa/k8s/keycape/README.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# T05c — KeyCape (OIDC Orchestration Layer)
|
||||
|
||||
KeyCape is the stateless OIDC server that ties the stack together. It orchestrates
|
||||
the full authentication flow:
|
||||
1. User visits a registered application
|
||||
2. Application redirects to KeyCape (`kc.coulomb.social`) for login
|
||||
3. KeyCape redirects the browser to Authelia (`auth.coulomb.social`) for password auth
|
||||
4. Authelia validates the password against LLDAP and returns an authorization code
|
||||
5. KeyCape exchanges the code for user identity, then calls privacyIDEA for MFA
|
||||
6. On success, KeyCape issues a signed OIDC token to the application
|
||||
|
||||
KeyCape is stateless — all state lives in Authelia (sessions), LLDAP (users), and
|
||||
privacyIDEA (MFA tokens). No PVC is required.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- T04 complete (privacyIDEA is Running and bootstrapped — admin account + enckey done)
|
||||
- T05a complete (LLDAP is Running)
|
||||
- T05b complete (Authelia is Running)
|
||||
- KeyCape container image built and available (see "Building the image" below)
|
||||
- `bootstrap/gen-secrets.sh` run
|
||||
- `kubectl` configured with cluster access
|
||||
|
||||
## Building the image
|
||||
|
||||
KeyCape has no published image. Build it from the source repository and make it
|
||||
available to K3s before applying `deployment.yaml`.
|
||||
|
||||
### Option A — Local import into K3s (dev/single-node)
|
||||
|
||||
```bash
|
||||
cd ~/key-cape
|
||||
docker build -t keycape:v0.1 .
|
||||
|
||||
# Import directly into the K3s containerd runtime (no registry needed)
|
||||
docker save keycape:v0.1 | sudo k3s ctr images import -
|
||||
|
||||
# After import, set imagePullPolicy: Never in deployment.yaml
|
||||
# (the image is now in the K3s local store, not a registry)
|
||||
```
|
||||
|
||||
### Option B — Private registry (production)
|
||||
|
||||
```bash
|
||||
cd ~/key-cape
|
||||
docker build -t <registry>/keycape:v0.1 .
|
||||
docker push <registry>/keycape:v0.1
|
||||
|
||||
# Update the image field in deployment.yaml:
|
||||
# image: <registry>/keycape:v0.1
|
||||
# imagePullPolicy: IfNotPresent (default) is correct for registry images.
|
||||
```
|
||||
|
||||
After building, update `deployment.yaml` line:
|
||||
```yaml
|
||||
image: keycape:v0.1 # replace with your actual tag
|
||||
```
|
||||
|
||||
## Apply order
|
||||
|
||||
```bash
|
||||
# 1. Create Secrets (config.yaml + key.pem)
|
||||
# Run this AFTER T04 bootstrap if you want the privacyIDEA token included.
|
||||
# If T04 is not yet done, run it now and re-run after create-pi-token.sh.
|
||||
cd sso-mfa/k8s/keycape
|
||||
chmod +x create-secrets.sh create-pi-token.sh
|
||||
./create-secrets.sh
|
||||
|
||||
# 2. Apply manifests
|
||||
kubectl apply -f deployment.yaml
|
||||
kubectl apply -f middleware.yaml
|
||||
kubectl apply -f ingress.yaml
|
||||
|
||||
# 3. Wait for pod to be ready
|
||||
kubectl rollout status deployment/keycape -n sso --timeout=60s
|
||||
```
|
||||
|
||||
## Post-deploy: inject privacyIDEA admin token
|
||||
|
||||
If T04 was not complete when you ran `create-secrets.sh`, the privacyIDEA admin
|
||||
token is a placeholder. After T04 bootstrap is done:
|
||||
|
||||
```bash
|
||||
# 1. Fetch the token from privacyIDEA and store it
|
||||
chmod +x create-pi-token.sh
|
||||
./create-pi-token.sh
|
||||
|
||||
# 2. Re-run create-secrets.sh to update keycape-config with the real token
|
||||
./create-secrets.sh
|
||||
|
||||
# 3. Restart KeyCape to pick up the new Secret
|
||||
kubectl rollout restart deployment/keycape -n sso
|
||||
```
|
||||
|
||||
## OIDC client registration
|
||||
|
||||
Downstream applications are registered in the `clients:` block in
|
||||
`keycape/create-secrets.sh`. After editing:
|
||||
|
||||
```bash
|
||||
./create-secrets.sh # regenerates keycape-config Secret
|
||||
kubectl rollout restart deployment/keycape -n sso
|
||||
```
|
||||
|
||||
Example entry (public client, PKCE, for a SPA):
|
||||
```yaml
|
||||
clients:
|
||||
- clientId: "my-app"
|
||||
displayName: "My Application"
|
||||
redirectUris:
|
||||
- "https://my-app.coulomb.social/callback"
|
||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||
grantTypes: ["authorization_code"]
|
||||
clientType: "public"
|
||||
```
|
||||
|
||||
## Secrets managed
|
||||
|
||||
| Secret name | Keys | Purpose |
|
||||
|-------------|------|---------|
|
||||
| `keycape-config` | `config.yaml` | Full KeyCape configuration (LLDAP URL + creds, Authelia URL + client secret, privacyIDEA URL + admin token, OIDC clients) |
|
||||
| | `key.pem` | RSA-2048 private key for signing OIDC tokens issued to downstream applications |
|
||||
| `keycape-pi-token` | `pi_admin_token` | privacyIDEA admin JWT — created by `create-pi-token.sh`, referenced in `config.yaml` |
|
||||
|
||||
Store `key.pem` in KeePassXC as a binary attachment. If it is lost, all active
|
||||
sessions become invalid (tokens cannot be verified) and all applications must
|
||||
re-authenticate.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# Pod status
|
||||
kubectl get pod -n sso -l app.kubernetes.io/name=keycape
|
||||
|
||||
# Health check
|
||||
kubectl run -n sso --rm -it kc-test --image=busybox --restart=Never \
|
||||
-- wget -qO- http://keycape.sso.svc.cluster.local:8080/healthz
|
||||
|
||||
# OIDC discovery (public endpoint)
|
||||
curl -s https://kc.coulomb.social/.well-known/openid-configuration | jq .
|
||||
|
||||
# Check issuer matches CP-NK-004
|
||||
curl -s https://kc.coulomb.social/.well-known/openid-configuration \
|
||||
| jq -r .issuer # should be: https://kc.coulomb.social
|
||||
```
|
||||
96
sso-mfa/k8s/keycape/create-pi-token.sh
Normal file
96
sso-mfa/k8s/keycape/create-pi-token.sh
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-pi-token.sh — fetch a privacyIDEA admin JWT and store it for KeyCape
|
||||
#
|
||||
# Usage:
|
||||
# ./create-pi-token.sh [secrets-dir]
|
||||
#
|
||||
# Run this script AFTER T04 bootstrap (privacyIDEA admin account created).
|
||||
# It authenticates to the privacyIDEA API, fetches a long-lived admin JWT,
|
||||
# and writes it to secrets/keycape/pi_admin_token.
|
||||
#
|
||||
# After running this script, re-run create-secrets.sh to update the
|
||||
# keycape-config K8s Secret with the real token, then restart KeyCape:
|
||||
# ./create-secrets.sh
|
||||
# kubectl rollout restart deployment/keycape -n sso
|
||||
#
|
||||
# The privacyIDEA admin token does NOT expire by default (it is a permanent
|
||||
# service account token). Store it in KeePassXC as:
|
||||
# net-kingdom/KeyCape/pi-admin-token
|
||||
#
|
||||
# Requires: kubectl, curl, jq
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_DIR="${1:-../../bootstrap/secrets}"
|
||||
PI_ENV="$SECRETS_DIR/privacyidea/secrets.env"
|
||||
TOKEN_FILE="$SECRETS_DIR/keycape/pi_admin_token"
|
||||
|
||||
if [[ ! -f "$PI_ENV" ]]; then
|
||||
echo "ERROR: $PI_ENV not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
|
||||
PI_ADMIN_PASSWORD=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD)
|
||||
|
||||
if [[ -z "$PI_ADMIN_PASSWORD" ]]; then
|
||||
echo "ERROR: PI_ADMIN_PASSWORD is empty in $PI_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine privacyIDEA base URL — use cluster-internal URL if kubectl is available
|
||||
# and we can reach the service, otherwise fall back to the public hostname.
|
||||
PI_BASE_URL=""
|
||||
if kubectl get service privacyidea -n mfa &>/dev/null 2>&1; then
|
||||
# Prefer running a one-shot pod inside the cluster to avoid needing
|
||||
# public TLS to be up during bootstrap.
|
||||
PI_BASE_URL="http://privacyidea.mfa.svc.cluster.local:8080"
|
||||
USE_CLUSTER=true
|
||||
else
|
||||
PI_BASE_URL="https://pink.coulomb.social"
|
||||
USE_CLUSTER=false
|
||||
fi
|
||||
|
||||
echo "Fetching privacyIDEA admin token from: $PI_BASE_URL"
|
||||
|
||||
if [[ "$USE_CLUSTER" == "true" ]]; then
|
||||
# Run curl inside the cluster (avoids needing public TLS to be live)
|
||||
TOKEN=$(kubectl run -n mfa --rm -i --restart=Never pi-token-fetch \
|
||||
--image=curlimages/curl:8 --quiet \
|
||||
-- curl -sf \
|
||||
-X POST "$PI_BASE_URL/auth" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=pi-admin&password=${PI_ADMIN_PASSWORD}" \
|
||||
2>/dev/null \
|
||||
| python3 -c "import sys,json; data=json.load(sys.stdin); print(data['result']['value']['token'])" \
|
||||
2>/dev/null || echo "")
|
||||
else
|
||||
TOKEN=$(curl -sf \
|
||||
-X POST "$PI_BASE_URL/auth" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=pi-admin&password=${PI_ADMIN_PASSWORD}" \
|
||||
| python3 -c "import sys,json; data=json.load(sys.stdin); print(data['result']['value']['token'])" \
|
||||
2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "ERROR: failed to fetch token from privacyIDEA." >&2
|
||||
echo " Verify that privacyIDEA is Running and the pi-admin account exists." >&2
|
||||
echo " Check: kubectl logs -n mfa \$(kubectl get pod -n mfa -l app.kubernetes.io/name=privacyidea -o name | head -1)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$TOKEN_FILE")"
|
||||
echo -n "$TOKEN" > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Token written to: $TOKEN_FILE"
|
||||
echo "Token preview : ${TOKEN:0:32}…"
|
||||
echo ""
|
||||
echo "IMPORTANT: Store this token in KeePassXC → net-kingdom/KeyCape/pi-admin-token"
|
||||
echo " as a password entry. It cannot be recovered without re-authenticating."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Re-run create-secrets.sh to update keycape-config with the real token."
|
||||
echo " 2. Restart KeyCape: kubectl rollout restart deployment/keycape -n sso"
|
||||
127
sso-mfa/k8s/keycape/create-secrets.sh
Normal file
127
sso-mfa/k8s/keycape/create-secrets.sh
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-secrets.sh — create the keycape-config K8s Secret
|
||||
#
|
||||
# Usage:
|
||||
# ./create-secrets.sh [secrets-dir]
|
||||
#
|
||||
# Creates ONE Secret in the sso namespace:
|
||||
# keycape-config — config.yaml (full KeyCape config) + key.pem (RSA signing key)
|
||||
#
|
||||
# The privacyIDEA admin token is a separate Secret (keycape-pi-token) created
|
||||
# by create-pi-token.sh AFTER privacyIDEA is bootstrapped (T04 complete).
|
||||
# The PI admin token is read from that Secret at startup via config.yaml.
|
||||
#
|
||||
# Re-run this script to:
|
||||
# - Rotate the Authelia client secret (update secrets/authelia/secrets.env first)
|
||||
# - Add or modify OIDC client registrations (edit CLIENTS block below)
|
||||
# - Rotate the RSA signing key (delete and regenerate secrets/keycape/key.pem)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_DIR="${1:-../../bootstrap/secrets}"
|
||||
KEYCAPE_ENV="$SECRETS_DIR/keycape/secrets.env"
|
||||
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
|
||||
AUTHELIA_ENV="$SECRETS_DIR/authelia/secrets.env"
|
||||
KEY_FILE="$SECRETS_DIR/keycape/key.pem"
|
||||
|
||||
for f in "$KEYCAPE_ENV" "$LLDAP_ENV" "$AUTHELIA_ENV"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "ERROR: $f not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
|
||||
|
||||
LLDAP_BIND_PW=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS)
|
||||
AUTHELIA_CLIENT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_KEYCAPE_CLIENT_SECRET)
|
||||
|
||||
if [[ -z "$LLDAP_BIND_PW" || -z "$AUTHELIA_CLIENT_SECRET" ]]; then
|
||||
echo "ERROR: could not read LLDAP_LDAP_USER_PASS or AUTHELIA_KEYCAPE_CLIENT_SECRET" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The privacyIDEA admin token is read from a separate Secret at runtime.
|
||||
# Placeholder here — create-pi-token.sh populates the real value.
|
||||
PI_ADMIN_TOKEN="PENDING_create-pi-token.sh"
|
||||
if [[ -f "$SECRETS_DIR/keycape/pi_admin_token" ]]; then
|
||||
PI_ADMIN_TOKEN=$(cat "$SECRETS_DIR/keycape/pi_admin_token")
|
||||
echo "INFO: Using privacyIDEA admin token from $SECRETS_DIR/keycape/pi_admin_token"
|
||||
fi
|
||||
|
||||
# ── RSA signing key ───────────────────────────────────────────────────────────
|
||||
if [[ ! -f "$KEY_FILE" ]]; then
|
||||
echo "Generating RSA-2048 signing key for KeyCape JWT tokens..."
|
||||
mkdir -p "$(dirname "$KEY_FILE")"
|
||||
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
|
||||
chmod 600 "$KEY_FILE"
|
||||
echo " Generated: $KEY_FILE"
|
||||
echo " IMPORTANT: Store this key in KeePassXC → net-kingdom/KeyCape/jwt-signing-key"
|
||||
echo " as a binary attachment. It cannot be recovered if lost."
|
||||
else
|
||||
echo "INFO: Using existing key: $KEY_FILE"
|
||||
fi
|
||||
KEY_CONTENT=$(cat "$KEY_FILE")
|
||||
|
||||
# ── Build config.yaml ─────────────────────────────────────────────────────────
|
||||
# Edit the OIDC clients block below to register downstream applications.
|
||||
# Re-run this script after any change.
|
||||
CONFIG_YAML=$(cat <<EOF
|
||||
issuer: "https://kc.coulomb.social"
|
||||
port: 8080
|
||||
tokenLifetime: "15m"
|
||||
privateKeyPem: "/etc/keycape/key.pem"
|
||||
environment: "production"
|
||||
|
||||
lldap:
|
||||
url: "ldap://lldap.sso.svc.cluster.local:3890"
|
||||
bindDN: "uid=admin,ou=people,dc=netkingdom,dc=local"
|
||||
bindPW: "${LLDAP_BIND_PW}"
|
||||
baseDN: "dc=netkingdom,dc=local"
|
||||
|
||||
authelia:
|
||||
baseURL: "http://authelia.sso.svc.cluster.local:9091"
|
||||
clientId: "keycape"
|
||||
clientSecret: "${AUTHELIA_CLIENT_SECRET}"
|
||||
redirectURI: "https://kc.coulomb.social/authorize/callback"
|
||||
|
||||
privacyidea:
|
||||
baseURL: "http://privacyidea.mfa.svc.cluster.local:8080"
|
||||
adminToken: "${PI_ADMIN_TOKEN}"
|
||||
realm: "netkingdom"
|
||||
|
||||
# ── OIDC client registrations ─────────────────────────────────────────────────
|
||||
# Add one entry per downstream application.
|
||||
# clientType: "public" for SPAs/native apps (PKCE, no client secret)
|
||||
# "confidential" for server-side apps (client secret required)
|
||||
clients: []
|
||||
# Example:
|
||||
# clients:
|
||||
# - clientId: "my-app"
|
||||
# displayName: "My Application"
|
||||
# redirectUris:
|
||||
# - "https://my-app.coulomb.social/callback"
|
||||
# allowedScopes: ["openid", "profile", "email", "groups"]
|
||||
# grantTypes: ["authorization_code"]
|
||||
# clientType: "public"
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "Creating K8s Secret: keycape-config (namespace: sso)"
|
||||
kubectl create secret generic keycape-config \
|
||||
--namespace=sso \
|
||||
--from-literal=config.yaml="$CONFIG_YAML" \
|
||||
--from-literal=key.pem="$KEY_CONTENT" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
echo ""
|
||||
echo "Done. Secret keycape-config created in namespace: sso"
|
||||
echo ""
|
||||
if [[ "$PI_ADMIN_TOKEN" == "PENDING_create-pi-token.sh" ]]; then
|
||||
echo "WARN: privacyIDEA admin token is a placeholder."
|
||||
echo " After T04 bootstrap is complete, run:"
|
||||
echo " ./create-pi-token.sh"
|
||||
echo " Then re-run this script to update keycape-config."
|
||||
echo ""
|
||||
fi
|
||||
echo "Next: apply deployment.yaml, middleware.yaml, ingress.yaml"
|
||||
136
sso-mfa/k8s/keycape/deployment.yaml
Normal file
136
sso-mfa/k8s/keycape/deployment.yaml
Normal file
@@ -0,0 +1,136 @@
|
||||
# Deployment + Service — KeyCape (namespace: sso)
|
||||
#
|
||||
# KeyCape is the OIDC orchestration layer. It is stateless: all persistent
|
||||
# state lives in Authelia (session), LLDAP (users), and privacyIDEA (MFA tokens).
|
||||
# No PVC is required.
|
||||
#
|
||||
# Configuration is stored entirely in the keycape-config Secret, which holds
|
||||
# a complete config.yaml and the RSA private key used to sign OIDC tokens
|
||||
# issued to downstream applications.
|
||||
#
|
||||
# Prerequisites (apply in order):
|
||||
# 1. keycape-config Secret — run keycape/create-secrets.sh
|
||||
# 2. keycape-pi-token Secret — run keycape/create-pi-token.sh (after T04 bootstrap)
|
||||
# 3. This file
|
||||
# 4. middleware.yaml + ingress.yaml
|
||||
#
|
||||
# Container image:
|
||||
# KeyCape has no published image. Build from ~/key-cape/ and push to a registry,
|
||||
# or import directly into K3s (see README.md "Building the image").
|
||||
# Image tag below is a placeholder — update before applying.
|
||||
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: keycape
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: keycape
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: keycape
|
||||
strategy:
|
||||
type: RollingUpdate # stateless — safe to roll
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: keycape
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534 # nobody — matches distroless static image
|
||||
fsGroup: 65534
|
||||
|
||||
containers:
|
||||
- name: keycape
|
||||
# EDIT before applying — see README.md "Building the image".
|
||||
# Option A (registry): docker build -t <registry>/keycape:v0.1 ~/key-cape/ && docker push ...
|
||||
# Option B (K3s local): docker build -t keycape:v0.1 ~/key-cape/ &&
|
||||
# docker save keycape:v0.1 | sudo k3s ctr images import -
|
||||
# After Option B, set imagePullPolicy: Never.
|
||||
image: keycape:v0.1
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
|
||||
env:
|
||||
- name: KEYCAPE_CONFIG
|
||||
value: /etc/keycape/config.yaml
|
||||
|
||||
volumeMounts:
|
||||
# keycape-config Secret provides config.yaml and key.pem
|
||||
- name: config-secret
|
||||
mountPath: /etc/keycape
|
||||
readOnly: true
|
||||
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 8080
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 3
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 8080
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 8080
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "25m"
|
||||
memory: "32Mi"
|
||||
limits:
|
||||
cpu: "200m"
|
||||
memory: "128Mi"
|
||||
|
||||
volumes:
|
||||
- name: config-secret
|
||||
secret:
|
||||
secretName: keycape-config
|
||||
# Secret must contain two keys: config.yaml and key.pem
|
||||
items:
|
||||
- key: config.yaml
|
||||
path: config.yaml
|
||||
- key: key.pem
|
||||
path: key.pem
|
||||
mode: 0400 # key.pem is sensitive; restrict to owner read only
|
||||
|
||||
---
|
||||
# Service — ClusterIP; Traefik reaches KeyCape via port 8080.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: keycape
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: keycape
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: keycape
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
42
sso-mfa/k8s/keycape/ingress.yaml
Normal file
42
sso-mfa/k8s/keycape/ingress.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Ingress — KeyCape OIDC server (namespace: sso)
|
||||
#
|
||||
# kc.coulomb.social — OIDC discovery, /authorize, /token, /jwks, /userinfo
|
||||
#
|
||||
# This hostname is public — applications redirect users here for login.
|
||||
# The auth.coulomb.social hostname (Authelia login UI) is where users
|
||||
# actually enter their passwords; browsers are redirected there by KeyCape.
|
||||
#
|
||||
# Config points (see CONFIG.md):
|
||||
# CP-NK-004 kc.coulomb.social
|
||||
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: keycape
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: keycape
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.middlewares: >-
|
||||
sso-keycape-rate-limit@kubernetescrd,
|
||||
sso-keycape-hsts@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: kc.coulomb.social
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: keycape
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: kc-tls
|
||||
hosts:
|
||||
- kc.coulomb.social
|
||||
36
sso-mfa/k8s/keycape/middleware.yaml
Normal file
36
sso-mfa/k8s/keycape/middleware.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
# Traefik Middlewares for KeyCape (namespace: sso)
|
||||
#
|
||||
# Middleware names referenced in ingress.yaml:
|
||||
# sso-keycape-rate-limit@kubernetescrd
|
||||
# sso-keycape-hsts@kubernetescrd
|
||||
|
||||
# ── Rate limit — all OIDC endpoints ──────────────────────────────────────────
|
||||
# OIDC discovery + JS app calls are bursty; keep limit generous.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: keycape-rate-limit
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
rateLimit:
|
||||
average: 100
|
||||
period: 1m
|
||||
burst: 20
|
||||
---
|
||||
# ── HSTS ─────────────────────────────────────────────────────────────────────
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: keycape-hsts
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
@@ -1,203 +0,0 @@
|
||||
# T05 — Phase 4: Deploy Keycloak
|
||||
|
||||
Phase 4 of NK-WP-0001: deploys the SSO core (Keycloak) in the `sso` namespace.
|
||||
|
||||
**Hostname (config point CP-NK-004):**
|
||||
- `kc.coulomb.social` — OIDC/SAML SSO portal, admin console
|
||||
|
||||
**Prerequisites:**
|
||||
- T02 complete: `sso` namespace and NetworkPolicies applied, cert-manager running.
|
||||
- T03 complete: PostgreSQL cluster `net-kingdom-pg` in `databases` namespace is Ready.
|
||||
- T04 complete: privacyIDEA is Running; `bootstrap-admin.sh` has been run so the
|
||||
`privacyidea-trigger-admin` Secret exists in the `mfa` namespace.
|
||||
- T01 Phase 0a complete: `gen-secrets.sh` run, all secrets in KeePassXC.
|
||||
|
||||
---
|
||||
|
||||
## Before you apply: two required edits
|
||||
|
||||
### Edit 1 — Provider JAR URL (CP-NK-005, required)
|
||||
|
||||
The init container in `deployment.yaml` downloads the privacyIDEA Keycloak Provider
|
||||
JAR. You must set the URL before applying:
|
||||
|
||||
1. Go to https://github.com/privacyIDEA/keycloak-provider/releases
|
||||
2. Download the JAR for a release compatible with your Keycloak image version.
|
||||
3. Edit `deployment.yaml`: find `PROVIDER_JAR_URL` and replace `EDIT_BEFORE_APPLY`
|
||||
with the real URL.
|
||||
4. Add the URL as CP-NK-005 in `CONFIG.md` (see bottom of this README).
|
||||
|
||||
If your cluster has no egress internet access, see **Custom image** below.
|
||||
|
||||
### Edit 2 — Admin console IP allowlist (optional but recommended)
|
||||
|
||||
Edit `middleware.yaml`: update `keycloak-admin-allowlist.spec.ipAllowList.sourceRange`
|
||||
to your actual VPN/office CIDRs.
|
||||
|
||||
---
|
||||
|
||||
## Apply order
|
||||
|
||||
### Step 1 — Create secrets
|
||||
|
||||
```bash
|
||||
cd sso-mfa/k8s/keycloak
|
||||
chmod +x create-secrets.sh bootstrap-realm.sh
|
||||
./create-secrets.sh
|
||||
```
|
||||
|
||||
Creates `keycloak-config` in the `sso` namespace (KC_DB_URL, KC_DB_PASSWORD,
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD).
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Set provider JAR URL and apply manifests
|
||||
|
||||
After editing `PROVIDER_JAR_URL` in `deployment.yaml`:
|
||||
|
||||
```bash
|
||||
# From sso-mfa/k8s/keycloak/
|
||||
kubectl apply -f pvc.yaml
|
||||
kubectl apply -f middleware.yaml
|
||||
kubectl apply -f deployment.yaml
|
||||
kubectl apply -f ingress.yaml
|
||||
```
|
||||
|
||||
**Wait for the pod to reach Running+Ready** (DB migrations + provider build on first
|
||||
boot — allow up to 5 minutes):
|
||||
|
||||
```bash
|
||||
kubectl get pods -n sso -w
|
||||
# Expected: keycloak-<hash> 1/1 Running
|
||||
```
|
||||
|
||||
If the pod is stuck in `Init`, check the init container logs first:
|
||||
```bash
|
||||
kubectl logs -n sso -l app.kubernetes.io/name=keycloak -c install-privacyidea-provider
|
||||
```
|
||||
|
||||
Common causes of `Init` failure:
|
||||
- `PROVIDER_JAR_URL` still set to `EDIT_BEFORE_APPLY` → edit and reapply
|
||||
- No egress internet access → use custom image (see below)
|
||||
|
||||
If the pod is in `CrashLoopBackOff`, check main container logs:
|
||||
```bash
|
||||
kubectl logs -n sso -l app.kubernetes.io/name=keycloak --previous
|
||||
```
|
||||
|
||||
Common causes of Keycloak crash:
|
||||
- `keycloak-config` Secret missing → run `create-secrets.sh`
|
||||
- PostgreSQL not reachable → verify T03, check NetworkPolicies
|
||||
- Wrong DB password → re-run `create-secrets.sh` with corrected secrets
|
||||
- `KC_DB_URL` format incorrect → must be `jdbc:postgresql://...` (not SQLAlchemy format)
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Bootstrap realm
|
||||
|
||||
After the pod is Running and Ready:
|
||||
|
||||
```bash
|
||||
./bootstrap-realm.sh
|
||||
```
|
||||
|
||||
This:
|
||||
1. Authenticates to the Keycloak admin REST API inside the pod.
|
||||
2. Hardens the master realm (SSL required, brute-force protection, token lifetimes).
|
||||
3. Creates the `net-kingdom` application realm with equivalent hardening.
|
||||
|
||||
**Immediately after bootstrap completes:**
|
||||
1. Log in to `https://kc.coulomb.social/admin` as `admin`.
|
||||
2. Create a permanent admin account (with MFA — configure MFA flow in T06 first).
|
||||
3. Disable or delete the bootstrap `admin` account once the permanent admin is enrolled.
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
```bash
|
||||
cd sso-mfa/k8s
|
||||
chmod +x verify-t05.sh
|
||||
./verify-t05.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom image (recommended for production)
|
||||
|
||||
The init-container approach downloads the provider JAR from the internet on every pod
|
||||
restart. For production or air-gapped clusters, build a custom image:
|
||||
|
||||
```dockerfile
|
||||
FROM quay.io/keycloak/keycloak:26.0
|
||||
# Add the privacyIDEA provider JAR (download from GitHub releases first)
|
||||
COPY keycloak-provider-VERSION.jar /opt/keycloak/providers/
|
||||
# Build an optimized image — this bakes in the provider at build time
|
||||
RUN /opt/keycloak/bin/kc.sh build
|
||||
```
|
||||
|
||||
Push to your registry, then:
|
||||
1. Update `deployment.yaml`: change the `keycloak` container `image` to your custom image.
|
||||
2. Change `args: ["start"]` to `args: ["start", "--optimized"]` for faster startup.
|
||||
3. Remove the `install-privacyidea-provider` init container entirely.
|
||||
4. The `providers` emptyDir volume and its mount can also be removed.
|
||||
|
||||
---
|
||||
|
||||
## NetworkPolicy design
|
||||
|
||||
Keycloak sits behind the NetworkPolicies applied in T02 (netpol-sso.yaml):
|
||||
|
||||
| Source | Destination | Port | Purpose |
|
||||
|--------|-------------|------|---------|
|
||||
| Traefik (kube-system) | Keycloak (sso) | 8080 | OIDC/SAML login pages |
|
||||
| Keycloak (sso) | privacyIDEA (mfa) | 8080 | MFA challenge API (T06) |
|
||||
| Keycloak (sso) | PostgreSQL (databases) | 5432 | Database |
|
||||
|
||||
Outbound to anything other than privacyIDEA, PostgreSQL, and kube-dns is denied.
|
||||
|
||||
---
|
||||
|
||||
## Post-deploy steps (after verify-t05.sh passes)
|
||||
|
||||
### Rotate the bootstrap admin password
|
||||
|
||||
The `KC_BOOTSTRAP_ADMIN_PASSWORD` in `keycloak-config` was the initial credential.
|
||||
After creating a permanent admin:
|
||||
|
||||
1. In Keycloak admin console: Users → admin → Disable account (or delete it).
|
||||
2. Rotate `KC_BOOTSTRAP_ADMIN_PASSWORD` in KeePassXC and re-run `create-secrets.sh`.
|
||||
3. Restart the Keycloak deployment: `kubectl rollout restart deployment/keycloak -n sso`
|
||||
|
||||
### Admin console IP restriction
|
||||
|
||||
Update `middleware.yaml` `keycloak-admin-allowlist.spec.ipAllowList.sourceRange`
|
||||
to your actual VPN/office CIDRs:
|
||||
|
||||
```bash
|
||||
kubectl apply -f middleware.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding CP-NK-005 to CONFIG.md
|
||||
|
||||
After setting the provider JAR URL, add it to `CONFIG.md` as CP-NK-005:
|
||||
|
||||
```markdown
|
||||
| CP-NK-005 | privacyIDEA provider JAR URL | <the URL you used> | `sso-mfa/k8s/keycloak/deployment.yaml` |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Disaster recovery
|
||||
|
||||
If the `keycloak-data` PVC is lost (build cache only — all state is in PostgreSQL):
|
||||
|
||||
1. Create a new PVC with `pvc.yaml`.
|
||||
2. Restart the deployment — Keycloak will rebuild from PostgreSQL on first start.
|
||||
Allow 3–5 minutes for the rebuild + DB reconnect.
|
||||
3. Realm config, clients, and users are preserved in the database.
|
||||
|
||||
If the PostgreSQL `keycloak_db` database is lost, restore from the CNPG backup
|
||||
(T03 procedure) before restarting Keycloak.
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# bootstrap-realm.sh — Phase 1 Keycloak bootstrap for T05
|
||||
#
|
||||
# Runs AFTER the Keycloak pod is Running and Ready (verify with verify-t05.sh).
|
||||
#
|
||||
# Actions (T05 scope):
|
||||
# 1. Authenticate to Keycloak admin REST API via kcadm.sh in the pod
|
||||
# 2. Harden master realm: brute-force protection, token lifetimes, SSL required
|
||||
# 3. Create the net-kingdom application realm
|
||||
# 4. Enable realm-level brute-force protection and SSL
|
||||
#
|
||||
# Out of scope for T05 (handled in T06):
|
||||
# - privacyIDEA authentication flow configuration
|
||||
# - User federation / LDAP resolver setup
|
||||
# - Per-client OIDC settings
|
||||
# - Break-glass admin account (T07)
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x bootstrap-realm.sh
|
||||
# ./bootstrap-realm.sh [--realm-name NAME]
|
||||
#
|
||||
# Options:
|
||||
# --realm-name NAME Name for the application realm (default: net-kingdom)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - kubectl configured with cluster access
|
||||
# - Keycloak pod Running+Ready in the sso namespace
|
||||
# - KC_ADMIN_PASSWORD available (prompted interactively if not set as env var)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAMESPACE="sso"
|
||||
REALM_NAME="net-kingdom"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--realm-name) REALM_NAME="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Find the running Keycloak pod ─────────────────────────────────────────────
|
||||
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 [[ -z "$KC_POD" ]]; then
|
||||
echo "ERROR: No running Keycloak pod found in namespace $NAMESPACE." >&2
|
||||
echo "Run verify-t05.sh to diagnose." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using pod: $KC_POD"
|
||||
|
||||
# ── Admin password ────────────────────────────────────────────────────────────
|
||||
if [[ -z "${KC_ADMIN_PASSWORD:-}" ]]; then
|
||||
echo -n "Keycloak admin password (KC_ADMIN_PASSWORD): "
|
||||
read -rs KC_ADMIN_PASSWORD
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ -z "$KC_ADMIN_PASSWORD" ]]; then
|
||||
echo "ERROR: KC_ADMIN_PASSWORD is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Helper: run kcadm.sh inside the pod ──────────────────────────────────────
|
||||
kcadm() {
|
||||
kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
|
||||
/opt/keycloak/bin/kcadm.sh "$@"
|
||||
}
|
||||
|
||||
# ── 1. Authenticate ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Authenticating ───────────────────────────────────────────────────────"
|
||||
kcadm config credentials \
|
||||
--server http://localhost:8080 \
|
||||
--realm master \
|
||||
--user admin \
|
||||
--password "$KC_ADMIN_PASSWORD"
|
||||
|
||||
echo " Authenticated as admin@master"
|
||||
|
||||
# ── 2. Harden master realm ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Hardening master realm ───────────────────────────────────────────────"
|
||||
|
||||
# Require SSL for all connections to master realm (external access requires HTTPS).
|
||||
kcadm update realms/master \
|
||||
--set 'sslRequired=external'
|
||||
echo " sslRequired=external (master)"
|
||||
|
||||
# Brute-force protection: lock after 5 failures for 5 minutes.
|
||||
kcadm update realms/master \
|
||||
--set 'bruteForceProtected=true' \
|
||||
--set 'failureFactor=5' \
|
||||
--set 'waitIncrementSeconds=60' \
|
||||
--set 'maxFailureWaitSeconds=900' \
|
||||
--set 'minimumQuickLoginWaitSeconds=60'
|
||||
echo " Brute-force protection enabled (master)"
|
||||
|
||||
# Shorten access token lifetime for security.
|
||||
kcadm update realms/master \
|
||||
--set 'accessTokenLifespan=300' # 5 min
|
||||
echo " Access token lifetime: 300s (master)"
|
||||
|
||||
# ── 3. Create the net-kingdom application realm ───────────────────────────────
|
||||
echo ""
|
||||
echo "── Creating realm: $REALM_NAME ─────────────────────────────────────────"
|
||||
|
||||
if kcadm get realms/"$REALM_NAME" &>/dev/null; then
|
||||
echo " Realm $REALM_NAME already exists — skipping creation."
|
||||
else
|
||||
kcadm create realms \
|
||||
--set "realm=$REALM_NAME" \
|
||||
--set 'enabled=true' \
|
||||
--set 'displayName=net-kingdom' \
|
||||
--set 'sslRequired=external' \
|
||||
--set 'registrationAllowed=false' \
|
||||
--set 'bruteForceProtected=true' \
|
||||
--set 'failureFactor=5' \
|
||||
--set 'waitIncrementSeconds=60' \
|
||||
--set 'maxFailureWaitSeconds=900' \
|
||||
--set 'minimumQuickLoginWaitSeconds=60' \
|
||||
--set 'accessTokenLifespan=300' \
|
||||
--set 'refreshTokenMaxReuse=0' \
|
||||
--set 'revokeRefreshToken=true'
|
||||
echo " Realm $REALM_NAME created."
|
||||
fi
|
||||
|
||||
# ── 4. Summary ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " T05 realm bootstrap complete."
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "What was done:"
|
||||
echo " - master realm: SSL required, brute-force protection, short token lifetimes"
|
||||
echo " - realm '$REALM_NAME' created with equivalent hardening"
|
||||
echo ""
|
||||
echo "Next steps (T06):"
|
||||
echo " 1. Log in to https://kc.coulomb.social/admin with the bootstrap admin."
|
||||
echo " Immediately create a permanent admin account, then delete/disable"
|
||||
echo " the bootstrap 'admin' account (or rotate KC_BOOTSTRAP_ADMIN_PASSWORD)."
|
||||
echo " 2. In realm '$REALM_NAME':"
|
||||
echo " - Configure the privacyIDEA authentication flow (see T06 README)."
|
||||
echo " - Set privacyIDEA base URL: https://pink.coulomb.social"
|
||||
echo " - Set trigger-admin credentials from the privacyidea-trigger-admin Secret."
|
||||
echo " 3. Run verify-t05.sh to confirm all T05 done-criteria."
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-secrets.sh — create the keycloak-config K8s Secret
|
||||
#
|
||||
# Usage:
|
||||
# ./create-secrets.sh [secrets-dir]
|
||||
#
|
||||
# <secrets-dir> is the output directory from sso-mfa/bootstrap/gen-secrets.sh
|
||||
# (default: ../../bootstrap/secrets).
|
||||
#
|
||||
# Creates ONE Secret in the sso namespace:
|
||||
# keycloak-config — KC_DB_URL, KC_DB_PASSWORD, KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||
#
|
||||
# This secret must exist before applying deployment.yaml.
|
||||
#
|
||||
# Re-run with --rotate to update secrets after a rotation in KeePassXC.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_DIR="${1:-../../bootstrap/secrets}"
|
||||
KC_ENV="$SECRETS_DIR/keycloak/secrets.env"
|
||||
PG_ENV="$SECRETS_DIR/postgres/secrets.env"
|
||||
|
||||
if [[ ! -d "$SECRETS_DIR" ]]; then
|
||||
echo "ERROR: secrets directory not found: $SECRETS_DIR" >&2
|
||||
echo "Run sso-mfa/bootstrap/gen-secrets.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for f in "$KC_ENV" "$PG_ENV"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "ERROR: $f not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Read values from the generated env files in subshells to avoid polluting env.
|
||||
KC_ADMIN_PASSWORD=$(bash -c "source '$KC_ENV' 2>/dev/null; echo \$KC_ADMIN_PASSWORD")
|
||||
KC_DB_PASSWORD=$(bash -c "source '$KC_ENV' 2>/dev/null; echo \$KC_DB_PASSWORD")
|
||||
|
||||
if [[ -z "$KC_ADMIN_PASSWORD" || -z "$KC_DB_PASSWORD" ]]; then
|
||||
echo "ERROR: could not read KC_ADMIN_PASSWORD or KC_DB_PASSWORD from $KC_ENV" >&2
|
||||
echo "Check that gen-secrets.sh ran successfully." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Construct the JDBC database URL.
|
||||
# CloudNativePG read-write service: net-kingdom-pg-rw.databases.svc.cluster.local
|
||||
# Keycloak uses JDBC format (jdbc:postgresql://...) — NOT the SQLAlchemy URI format.
|
||||
KC_DB_URL="jdbc:postgresql://net-kingdom-pg-rw.databases.svc.cluster.local:5432/keycloak_db"
|
||||
|
||||
echo "Creating K8s Secret: keycloak-config (namespace: sso)"
|
||||
kubectl create secret generic keycloak-config \
|
||||
--namespace=sso \
|
||||
--from-literal=KC_DB_URL="$KC_DB_URL" \
|
||||
--from-literal=KC_DB_PASSWORD="$KC_DB_PASSWORD" \
|
||||
--from-literal=KC_BOOTSTRAP_ADMIN_PASSWORD="$KC_ADMIN_PASSWORD" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
echo ""
|
||||
echo "Done. Secret keycloak-config created in namespace: sso"
|
||||
echo ""
|
||||
echo "Next:"
|
||||
echo " 1. Edit deployment.yaml: set PROVIDER_JAR_URL to the privacyIDEA provider JAR URL (CP-NK-005)."
|
||||
echo " 2. Apply manifests (see README.md apply order)."
|
||||
echo " 3. After the pod is Running+Ready, run: ./bootstrap-realm.sh"
|
||||
@@ -1,251 +0,0 @@
|
||||
# Deployment + Service — Keycloak (namespace: sso)
|
||||
#
|
||||
# Prerequisites (apply in order):
|
||||
# 1. pvc.yaml — keycloak-data PVC
|
||||
# 2. middleware.yaml — Traefik middlewares
|
||||
# 3. create-secrets.sh — keycloak-config Secret (KC_DB_URL, KC_DB_PASSWORD,
|
||||
# KC_BOOTSTRAP_ADMIN_PASSWORD)
|
||||
# 4. Edit PROVIDER_JAR_URL in the init container below (CP-NK-005 — see CONFIG.md)
|
||||
# 5. This file
|
||||
#
|
||||
# After first pod starts successfully:
|
||||
# 6. bootstrap-realm.sh — configure master realm, create net-kingdom realm
|
||||
#
|
||||
# privacyIDEA Keycloak Provider (init container):
|
||||
# The init container downloads the provider JAR to /opt/keycloak/providers/
|
||||
# before Keycloak starts. Keycloak detects providers and rebuilds automatically
|
||||
# (no --optimized flag). Build output is cached in the keycloak-data PVC so
|
||||
# subsequent restarts skip the full rebuild.
|
||||
#
|
||||
# For production, prefer building a custom image with the provider pre-baked:
|
||||
# FROM quay.io/keycloak/keycloak:VERSION
|
||||
# COPY keycloak-provider-VERSION.jar /opt/keycloak/providers/
|
||||
# RUN /opt/keycloak/bin/kc.sh build
|
||||
# A custom image avoids the internet dependency and ensures a reproducible build.
|
||||
# See README.md "Custom image" section for details.
|
||||
#
|
||||
# Container ports:
|
||||
# 8080 — HTTP (Traefik ingress; TLS terminated at Traefik)
|
||||
# 9000 — Management (health/live, health/ready, health/started — kubelet only)
|
||||
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
strategy:
|
||||
type: Recreate # single replica — avoid two pods racing on the build-cache PVC
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000 # keycloak user inside the official image
|
||||
fsGroup: 1000
|
||||
|
||||
# ── Init: download privacyIDEA Keycloak Provider JAR ─────────────────
|
||||
# The JAR is placed in /opt/keycloak/providers/ via an emptyDir volume.
|
||||
# Keycloak detects the new provider on startup and rebuilds automatically.
|
||||
#
|
||||
# NOTE: This requires outbound HTTPS from the cluster to GitHub.
|
||||
# If your cluster has no egress internet access, pre-stage the JAR in an
|
||||
# internal registry or use a custom Keycloak image (see comment above).
|
||||
initContainers:
|
||||
- name: install-privacyidea-provider
|
||||
# Pin curl image version alongside the Keycloak image.
|
||||
image: curlimages/curl:8.10.1
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534 # nobody — curl image default
|
||||
env:
|
||||
- name: PROVIDER_JAR_URL
|
||||
# CP-NK-005 — EDIT this value before applying.
|
||||
# Find the correct release at:
|
||||
# https://github.com/privacyIDEA/keycloak-provider/releases
|
||||
# Choose a version compatible with your Keycloak image version above.
|
||||
# Example:
|
||||
# https://github.com/privacyIDEA/keycloak-provider/releases/download/v0.9/keycloak-provider-0.9.jar
|
||||
value: "EDIT_BEFORE_APPLY"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
if [ "$PROVIDER_JAR_URL" = "EDIT_BEFORE_APPLY" ]; then
|
||||
echo "ERROR: PROVIDER_JAR_URL not set." >&2
|
||||
echo "Edit deployment.yaml and replace PROVIDER_JAR_URL with the real JAR URL." >&2
|
||||
echo "See CONFIG.md CP-NK-005 and README.md." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Downloading privacyIDEA Keycloak Provider from: $PROVIDER_JAR_URL"
|
||||
curl -fsSL -o /providers/keycloak-provider.jar "$PROVIDER_JAR_URL"
|
||||
BYTES=$(wc -c < /providers/keycloak-provider.jar)
|
||||
echo "Downloaded: ${BYTES} bytes -> /providers/keycloak-provider.jar"
|
||||
volumeMounts:
|
||||
- name: providers
|
||||
mountPath: /providers
|
||||
|
||||
containers:
|
||||
- name: keycloak
|
||||
# Pin to a specific release; update via image update policy.
|
||||
# Check https://quay.io/repository/keycloak/keycloak for latest stable.
|
||||
image: quay.io/keycloak/keycloak:26.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
# kc.sh start — no --optimized flag so Keycloak rebuilds when providers change.
|
||||
# After the first successful start, subsequent starts use the cached build
|
||||
# in the keycloak-data PVC and restart within ~30 seconds.
|
||||
args: ["start"]
|
||||
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: management
|
||||
containerPort: 9000
|
||||
protocol: TCP
|
||||
|
||||
# ── Environment — sensitive values from Secret ──────────────────
|
||||
env:
|
||||
# Database
|
||||
- name: KC_DB
|
||||
value: postgres
|
||||
- name: KC_DB_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: keycloak-config
|
||||
key: KC_DB_URL
|
||||
- name: KC_DB_USERNAME
|
||||
value: keycloak
|
||||
- name: KC_DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: keycloak-config
|
||||
key: KC_DB_PASSWORD
|
||||
|
||||
# Bootstrap admin (used only on first start to create the admin user)
|
||||
- name: KC_BOOTSTRAP_ADMIN_USERNAME
|
||||
value: admin
|
||||
- name: KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: keycloak-config
|
||||
key: KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||
|
||||
# Hostname & proxy
|
||||
# CP-NK-004 — update CONFIG.md if you change this value.
|
||||
- name: KC_HOSTNAME
|
||||
value: kc.coulomb.social
|
||||
# Traefik passes X-Forwarded-For and X-Forwarded-Proto headers.
|
||||
- name: KC_PROXY_HEADERS
|
||||
value: xforwarded
|
||||
# TLS is terminated at Traefik; Keycloak serves HTTP inside the cluster.
|
||||
- name: KC_HTTP_ENABLED
|
||||
value: "true"
|
||||
|
||||
# Observability
|
||||
- name: KC_HEALTH_ENABLED
|
||||
value: "true"
|
||||
- name: KC_METRICS_ENABLED
|
||||
value: "true"
|
||||
- name: KC_LOG_LEVEL
|
||||
value: INFO
|
||||
|
||||
# Caching — local = in-JVM Infinispan; switch to ispn for multi-replica HA.
|
||||
- name: KC_CACHE
|
||||
value: local
|
||||
|
||||
# ── Volume mounts ───────────────────────────────────────────────
|
||||
volumeMounts:
|
||||
# providers emptyDir: populated by init container before Keycloak starts
|
||||
- name: providers
|
||||
mountPath: /opt/keycloak/providers
|
||||
readOnly: true
|
||||
# data PVC: Keycloak build cache (data/generated/) and runtime data
|
||||
- name: data
|
||||
mountPath: /opt/keycloak/data
|
||||
|
||||
# ── Probes ──────────────────────────────────────────────────────
|
||||
# Keycloak 24+: health endpoints on management port 9000.
|
||||
# /health/started — true once the application has completed startup.
|
||||
# /health/live — true unless the application is in an unrecoverable state.
|
||||
# /health/ready — true once Keycloak can serve requests.
|
||||
#
|
||||
# Startup: allow up to 5 min for DB migrations + provider build on first boot.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/started
|
||||
port: 9000
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30 # 30 × 10s = 5 min
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 9000
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 9000
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
# ── Resources ───────────────────────────────────────────────────
|
||||
# Keycloak is JVM-based; the initial provider build spikes CPU briefly.
|
||||
# Raise limits for production.
|
||||
resources:
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "1Gi"
|
||||
|
||||
# ── Volumes ─────────────────────────────────────────────────────────
|
||||
volumes:
|
||||
# providers emptyDir: re-populated by init container on every pod start.
|
||||
# Keycloak detects the JAR and checks whether a rebuild is needed.
|
||||
# If the JAR hash matches the cached build, startup is fast (~30s).
|
||||
- name: providers
|
||||
emptyDir: {}
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: keycloak-data
|
||||
|
||||
---
|
||||
# Service — ClusterIP; Traefik reaches Keycloak via port 8080.
|
||||
# Port 9000 (management) is NOT exposed — kubelet probes reach it directly on the pod.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: keycloak
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
@@ -1,87 +0,0 @@
|
||||
# Ingress — Keycloak (namespace: sso)
|
||||
#
|
||||
# kc.coulomb.social — SSO portal, OIDC/SAML endpoints, user login
|
||||
#
|
||||
# TLS: cert-manager issues the certificate via the letsencrypt-prod ClusterIssuer
|
||||
# (T02). Public DNS for kc.coulomb.social must resolve to the cluster's external
|
||||
# IP before cert-manager can complete the ACME HTTP-01 challenge.
|
||||
#
|
||||
# Middlewares:
|
||||
# keycloak-rate-limit — 100 req/min per IP (all paths)
|
||||
# keycloak-admin-allowlist — restrict /admin/* to VPN/office IPs (see middleware.yaml)
|
||||
# keycloak-hsts — inject HSTS header on all HTTPS responses
|
||||
#
|
||||
# Config points (see CONFIG.md):
|
||||
# CP-NK-004 kc.coulomb.social
|
||||
|
||||
# ── Main portal — kc.coulomb.social ──────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.middlewares: >-
|
||||
sso-keycloak-rate-limit@kubernetescrd,
|
||||
sso-keycloak-hsts@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: kc.coulomb.social
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: keycloak
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: kc-tls
|
||||
hosts:
|
||||
- kc.coulomb.social
|
||||
---
|
||||
# ── Admin console — kc.coulomb.social/admin — restricted to VPN/office IPs ──
|
||||
# Separate Ingress so the admin-allowlist middleware applies only to /admin/*.
|
||||
# Traefik prefers the more-specific /admin prefix over the / prefix above.
|
||||
#
|
||||
# The admin console path in Keycloak 20+ is /admin/.
|
||||
# Realm-specific admin REST API is at /admin/realms/{realm}/...
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: keycloak-admin
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.middlewares: >-
|
||||
sso-keycloak-rate-limit@kubernetescrd,
|
||||
sso-keycloak-admin-allowlist@kubernetescrd,
|
||||
sso-keycloak-hsts@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: kc.coulomb.social
|
||||
http:
|
||||
paths:
|
||||
- path: /admin
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: keycloak
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: kc-tls
|
||||
hosts:
|
||||
- kc.coulomb.social
|
||||
@@ -1,71 +0,0 @@
|
||||
# Traefik Middlewares for Keycloak (namespace: sso)
|
||||
#
|
||||
# Middleware names follow the pattern referenced in ingress.yaml annotations:
|
||||
# sso-keycloak-rate-limit@kubernetescrd
|
||||
# sso-keycloak-admin-allowlist@kubernetescrd
|
||||
# sso-keycloak-hsts@kubernetescrd
|
||||
#
|
||||
# Traefik API version:
|
||||
# Traefik v3 (K3s >= 1.30): traefik.io/v1alpha1
|
||||
# Traefik v2 (K3s < 1.30): traefik.containo.us/v1alpha1
|
||||
# Check: kubectl get middleware -n sso -o yaml | grep apiVersion
|
||||
# Update all documents below if you need the v2 apiVersion.
|
||||
|
||||
# ── Rate limit — all KC endpoints ────────────────────────────────────────────
|
||||
# 100 requests/minute per client IP; burst of 20 allowed.
|
||||
# Higher than privacyIDEA because OIDC discovery + JS app calls are bursty.
|
||||
# The /realms/{realm}/.well-known/openid-configuration call alone counts.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: keycloak-rate-limit
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
rateLimit:
|
||||
average: 100
|
||||
period: 1m
|
||||
burst: 20
|
||||
---
|
||||
# ── Admin console allowlist — restrict /admin to VPN/office IPs ──────────────
|
||||
# Applied to the /admin Ingress (see ingress.yaml — separate Ingress for /admin/).
|
||||
#
|
||||
# ADJUST sourceRange to your actual VPN / office CIDR(s) before going live.
|
||||
# Leaving RFC-1918 ranges here is only a dev/staging default.
|
||||
#
|
||||
# Traefik v3 uses ipAllowList; Traefik v2 uses ipWhiteList.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: keycloak-admin-allowlist
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
ipAllowList:
|
||||
# EDIT: replace with your VPN/office CIDRs (see CONFIG.md for the pattern).
|
||||
sourceRange:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
---
|
||||
# ── HSTS — HTTP Strict Transport Security ────────────────────────────────────
|
||||
# Keycloak docs recommend HSTS for all deployments.
|
||||
# Traefik terminates TLS; Keycloak runs HTTP internally.
|
||||
# This header is injected by Traefik on all HTTPS responses.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: keycloak-hsts
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
headers:
|
||||
stsSeconds: 31536000 # 1 year
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
@@ -1,22 +0,0 @@
|
||||
# PersistentVolumeClaim for Keycloak (namespace: sso)
|
||||
#
|
||||
# keycloak-data — /opt/keycloak/data
|
||||
# Holds: Keycloak build cache (data/generated/) produced by kc.sh build.
|
||||
# Persisting this avoids a full provider rebuild on every pod restart.
|
||||
# Also holds H2 emergency data (only used if PostgreSQL is unreachable).
|
||||
#
|
||||
# Adjust storage size before production deployment.
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: keycloak-data
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
93
sso-mfa/k8s/lldap/README.md
Normal file
93
sso-mfa/k8s/lldap/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# T05a — LLDAP (Lightweight LDAP Directory)
|
||||
|
||||
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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- T02 complete (namespaces and NetworkPolicies applied)
|
||||
- `bootstrap/gen-secrets.sh` run and `secrets/lldap/secrets.env` populated in KeePassXC
|
||||
- `kubectl` configured with cluster access
|
||||
|
||||
## Apply order
|
||||
|
||||
```bash
|
||||
# 1. Generate secrets (if not already done)
|
||||
cd ../../bootstrap && ./gen-secrets.sh
|
||||
|
||||
# 2. Create K8s Secret
|
||||
cd ../k8s/lldap
|
||||
chmod +x create-secrets.sh
|
||||
./create-secrets.sh
|
||||
|
||||
# 3. Apply manifests (order matters)
|
||||
kubectl apply -f pvc.yaml
|
||||
kubectl apply -f middleware.yaml
|
||||
kubectl apply -f deployment.yaml
|
||||
kubectl apply -f ingress.yaml
|
||||
|
||||
# 4. Wait for pod to be ready
|
||||
kubectl rollout status deployment/lldap -n sso --timeout=120s
|
||||
```
|
||||
|
||||
## Post-deploy bootstrap
|
||||
|
||||
After the pod is Running, create the two required application groups via the web UI:
|
||||
|
||||
```
|
||||
https://lldap.coulomb.social
|
||||
Username: admin
|
||||
Password: LLDAP_LDAP_USER_PASS (from KeePassXC → net-kingdom/LLDAP/admin)
|
||||
```
|
||||
|
||||
Create groups:
|
||||
- `net-kingdom-users` — standard users
|
||||
- `net-kingdom-admins` — privileged users (enforce MFA step-up in KeyCape policies)
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Protocol | Access | Purpose |
|
||||
|------|----------|--------|---------|
|
||||
| 3890 | TCP (LDAP) | Cluster-internal only | Authelia + KeyCape LDAP bind |
|
||||
| 17170 | TCP (HTTP) | Traefik (IP-restricted) | Admin web UI |
|
||||
|
||||
The LDAP port is never exposed via Ingress. Only pods in the `sso` namespace
|
||||
with `app.kubernetes.io/name=authelia` or `app.kubernetes.io/name=keycape` labels
|
||||
are allowed to reach port 3890 (enforced by NetworkPolicy).
|
||||
|
||||
## Secrets managed
|
||||
|
||||
| Secret name | Keys | Purpose |
|
||||
|-------------|------|---------|
|
||||
| `lldap-secrets` | `LLDAP_JWT_SECRET`, `LLDAP_LDAP_USER_PASS` | Pod environment variables |
|
||||
|
||||
`LLDAP_LDAP_USER_PASS` is the admin bind password shared by Authelia and KeyCape.
|
||||
It must match the value used in `authelia/create-secrets.sh` and `keycape/create-secrets.sh`.
|
||||
All three read it from `secrets/lldap/secrets.env`.
|
||||
|
||||
## Storage
|
||||
|
||||
`lldap-data` PVC (1 Gi, ReadWriteOnce) holds LLDAP's SQLite database.
|
||||
|
||||
**Back this PVC up regularly** — it contains all users and groups. If it is lost
|
||||
without a backup, all user accounts must be re-created and all applications must
|
||||
be re-enrolled in privacyIDEA.
|
||||
|
||||
Optional: switch to PostgreSQL by setting `LLDAP_DATABASE_URL=postgresql://...`
|
||||
env var in `deployment.yaml` and removing the PVC.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl get pod -n sso -l app.kubernetes.io/name=lldap
|
||||
|
||||
# Check LLDAP health via cluster-internal curl
|
||||
kubectl run -n sso --rm -it ldap-test --image=busybox --restart=Never \
|
||||
-- wget -qO- http://lldap.sso.svc.cluster.local:17170/health
|
||||
|
||||
# Test LDAP bind (from another pod in the sso namespace)
|
||||
# ldapwhoami -H ldap://lldap.sso.svc.cluster.local:3890 \
|
||||
# -D "uid=admin,ou=people,dc=netkingdom,dc=local" -w <LLDAP_LDAP_USER_PASS>
|
||||
```
|
||||
57
sso-mfa/k8s/lldap/create-secrets.sh
Normal file
57
sso-mfa/k8s/lldap/create-secrets.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-secrets.sh — create the lldap-secrets K8s Secret
|
||||
#
|
||||
# Usage:
|
||||
# ./create-secrets.sh [secrets-dir]
|
||||
#
|
||||
# <secrets-dir> is the output directory from sso-mfa/bootstrap/gen-secrets.sh
|
||||
# (default: ../../bootstrap/secrets).
|
||||
#
|
||||
# Creates ONE Secret in the sso namespace:
|
||||
# lldap-secrets — LLDAP_JWT_SECRET, LLDAP_LDAP_USER_PASS
|
||||
#
|
||||
# LLDAP_LDAP_USER_PASS is also used as the LDAP bind password
|
||||
# by Authelia (authelia/create-secrets.sh) and KeyCape (keycape/create-secrets.sh).
|
||||
# All three read the same value from secrets/lldap/secrets.env.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_DIR="${1:-../../bootstrap/secrets}"
|
||||
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
|
||||
|
||||
if [[ ! -d "$SECRETS_DIR" ]]; then
|
||||
echo "ERROR: secrets directory not found: $SECRETS_DIR" >&2
|
||||
echo "Run sso-mfa/bootstrap/gen-secrets.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$LLDAP_ENV" ]]; then
|
||||
echo "ERROR: $LLDAP_ENV not found" >&2
|
||||
echo "If you ran gen-secrets.sh before the KeyCape migration, re-run it to add LLDAP secrets." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LLDAP_JWT_SECRET=$(bash -c "source '$LLDAP_ENV' 2>/dev/null; echo \$LLDAP_JWT_SECRET")
|
||||
LLDAP_LDAP_USER_PASS=$(bash -c "source '$LLDAP_ENV' 2>/dev/null; echo \$LLDAP_LDAP_USER_PASS")
|
||||
|
||||
if [[ -z "$LLDAP_JWT_SECRET" || -z "$LLDAP_LDAP_USER_PASS" ]]; then
|
||||
echo "ERROR: could not read LLDAP_JWT_SECRET or LLDAP_LDAP_USER_PASS from $LLDAP_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating K8s Secret: lldap-secrets (namespace: sso)"
|
||||
kubectl create secret generic lldap-secrets \
|
||||
--namespace=sso \
|
||||
--from-literal=LLDAP_JWT_SECRET="$LLDAP_JWT_SECRET" \
|
||||
--from-literal=LLDAP_LDAP_USER_PASS="$LLDAP_LDAP_USER_PASS" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
echo ""
|
||||
echo "Done. Secret lldap-secrets created in namespace: sso"
|
||||
echo ""
|
||||
echo "Next:"
|
||||
echo " Apply manifests (see README.md apply order)."
|
||||
echo " After LLDAP is Running, create application groups:"
|
||||
echo " - Log in to https://lldap.coulomb.social with the admin account."
|
||||
echo " - Create group: net-kingdom-users"
|
||||
echo " - Create group: net-kingdom-admins"
|
||||
137
sso-mfa/k8s/lldap/deployment.yaml
Normal file
137
sso-mfa/k8s/lldap/deployment.yaml
Normal file
@@ -0,0 +1,137 @@
|
||||
# Deployment + Service — LLDAP (namespace: sso)
|
||||
#
|
||||
# LLDAP is the lightweight LDAP directory backing both Authelia (credential
|
||||
# validation) and KeyCape (user attribute lookup). Configured via environment
|
||||
# variables only; no config file is needed.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. pvc.yaml — lldap-data PVC
|
||||
# 2. create-secrets.sh — lldap-secrets (LLDAP_JWT_SECRET, LLDAP_LDAP_USER_PASS)
|
||||
# 3. This file
|
||||
#
|
||||
# Ports:
|
||||
# 3890 — LDAP (internal only; Authelia and KeyCape reach LLDAP here)
|
||||
# 17170 — Web UI (ingress restricted to VPN via middleware — see ingress.yaml)
|
||||
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: lldap
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: lldap
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: lldap
|
||||
strategy:
|
||||
type: Recreate # single replica; SQLite cannot be accessed concurrently
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: lldap
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
|
||||
containers:
|
||||
- name: lldap
|
||||
# Check https://hub.docker.com/r/lldap/lldap for latest stable tag.
|
||||
image: lldap/lldap:stable
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
ports:
|
||||
- name: ldap
|
||||
containerPort: 3890
|
||||
protocol: TCP
|
||||
- name: web-ui
|
||||
containerPort: 17170
|
||||
protocol: TCP
|
||||
|
||||
env:
|
||||
- name: LLDAP_LDAP_BASE_DN
|
||||
value: dc=netkingdom,dc=local
|
||||
- name: LLDAP_HTTP_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: LLDAP_LDAP_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: LLDAP_HTTP_PORT
|
||||
value: "17170"
|
||||
- name: LLDAP_LDAP_PORT
|
||||
value: "3890"
|
||||
# Sensitive values from Secret
|
||||
- name: LLDAP_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lldap-secrets
|
||||
key: LLDAP_JWT_SECRET
|
||||
- name: LLDAP_LDAP_USER_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lldap-secrets
|
||||
key: LLDAP_LDAP_USER_PASS
|
||||
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
|
||||
# LLDAP health check — HTTP endpoint at /health on web UI port
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 17170
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 17170
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "50m"
|
||||
memory: "64Mi"
|
||||
limits:
|
||||
cpu: "200m"
|
||||
memory: "128Mi"
|
||||
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: lldap-data
|
||||
|
||||
---
|
||||
# Service — ClusterIP; LDAP port for Authelia/KeyCape, Web UI for Traefik.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: lldap
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: lldap
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: lldap
|
||||
ports:
|
||||
- name: ldap
|
||||
port: 3890
|
||||
targetPort: 3890
|
||||
protocol: TCP
|
||||
- name: web-ui
|
||||
port: 17170
|
||||
targetPort: 17170
|
||||
protocol: TCP
|
||||
39
sso-mfa/k8s/lldap/ingress.yaml
Normal file
39
sso-mfa/k8s/lldap/ingress.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Ingress — LLDAP web UI (namespace: sso)
|
||||
#
|
||||
# lldap.coulomb.social — admin web UI for user/group management
|
||||
#
|
||||
# This hostname is VPN/office-only; the lldap-admin-allowlist middleware
|
||||
# blocks all other source IPs at the Traefik layer.
|
||||
#
|
||||
# Config points (see CONFIG.md):
|
||||
# CP-NK-006 lldap.coulomb.social
|
||||
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: lldap
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/name: lldap
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.middlewares: "sso-lldap-admin-allowlist@kubernetescrd"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: lldap.coulomb.social
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lldap
|
||||
port:
|
||||
number: 17170
|
||||
tls:
|
||||
- secretName: lldap-tls
|
||||
hosts:
|
||||
- lldap.coulomb.social
|
||||
25
sso-mfa/k8s/lldap/middleware.yaml
Normal file
25
sso-mfa/k8s/lldap/middleware.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Traefik Middleware for LLDAP web UI (namespace: sso)
|
||||
#
|
||||
# The LLDAP web UI is admin-only and must never be accessible from the internet.
|
||||
# This middleware restricts access to VPN/office IPs.
|
||||
#
|
||||
# Middleware name referenced in ingress.yaml:
|
||||
# sso-lldap-admin-allowlist@kubernetescrd
|
||||
#
|
||||
# ADJUST sourceRange to your actual VPN / office CIDR(s) before going live.
|
||||
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: lldap-admin-allowlist
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
ipAllowList:
|
||||
# EDIT: replace with your VPN/office CIDRs.
|
||||
sourceRange:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
21
sso-mfa/k8s/lldap/pvc.yaml
Normal file
21
sso-mfa/k8s/lldap/pvc.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
# PersistentVolumeClaim for LLDAP (namespace: sso)
|
||||
#
|
||||
# lldap-data — /data
|
||||
# Holds: LLDAP's SQLite database (users, groups, keys).
|
||||
# LLDAP can also be configured to use PostgreSQL
|
||||
# (set LLDAP_DATABASE_URL=postgresql://...) if you want it on the shared
|
||||
# CloudNativePG cluster. SQLite on a PVC is sufficient for the lightweight mode.
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: lldap-data
|
||||
namespace: sso
|
||||
labels:
|
||||
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||
net-kingdom/component: sso
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -1,12 +1,24 @@
|
||||
# NetworkPolicies for the sso namespace (Keycloak)
|
||||
# NetworkPolicies for the sso namespace (KeyCape + Authelia + LLDAP)
|
||||
#
|
||||
# Allowed paths:
|
||||
# INGRESS: Traefik (kube-system) → Keycloak :8080
|
||||
# EGRESS: Keycloak → databases :5432 (PostgreSQL)
|
||||
# EGRESS: Keycloak → mfa :8080 (privacyIDEA API)
|
||||
# EGRESS: all pods → kube-dns :53 (UDP+TCP)
|
||||
# Components in this namespace:
|
||||
# keycape — OIDC orchestration layer (port 8080)
|
||||
# authelia — authentication frontend (port 9091)
|
||||
# lldap — LDAP directory (port 3890 LDAP, port 17170 Web UI)
|
||||
#
|
||||
# Everything else is denied.
|
||||
# Allowed ingress paths:
|
||||
# Traefik → keycape :8080 (OIDC endpoints, user-facing)
|
||||
# Traefik → authelia :9091 (login portal, user-facing)
|
||||
# Traefik → lldap :17170 (admin web UI; IP-restricted at Traefik layer)
|
||||
#
|
||||
# Allowed egress paths:
|
||||
# keycape → authelia :9091 (OIDC callback orchestration)
|
||||
# keycape → lldap :3890 (LDAP user lookups)
|
||||
# keycape → mfa :8080 (privacyIDEA MFA check and token validation)
|
||||
# authelia → lldap :3890 (LDAP authentication backend)
|
||||
# all pods → kube-dns :53 (DNS resolution)
|
||||
#
|
||||
# No egress to databases namespace — KeyCape is stateless;
|
||||
# LLDAP uses SQLite on a PVC (no external DB needed in lightweight mode).
|
||||
|
||||
# ── Default deny all ingress and egress ──────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
@@ -20,18 +32,16 @@ spec:
|
||||
- Ingress
|
||||
- Egress
|
||||
---
|
||||
# ── Allow ingress from Traefik (K3s ingress controller) ─────────────────────
|
||||
# Traefik terminates TLS; Keycloak listens on HTTP :8080 internally.
|
||||
# Traefik pods are in kube-system with label app.kubernetes.io/name=traefik.
|
||||
# ── Traefik → KeyCape :8080 ───────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-ingress-from-traefik
|
||||
name: allow-traefik-to-keycape
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
app.kubernetes.io/name: keycape
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
@@ -46,38 +56,162 @@ spec:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
---
|
||||
# ── Allow egress to PostgreSQL ───────────────────────────────────────────────
|
||||
# ── Traefik → Authelia :9091 ──────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-to-postgres
|
||||
name: allow-traefik-to-authelia
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
app.kubernetes.io/name: authelia
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- port: 9091
|
||||
protocol: TCP
|
||||
---
|
||||
# ── Traefik → LLDAP :17170 (admin web UI) ────────────────────────────────────
|
||||
# IP-based restriction is enforced at the Traefik layer (lldap-admin-allowlist
|
||||
# middleware in lldap/middleware.yaml). This NetworkPolicy opens the port;
|
||||
# Traefik enforces the IP allowlist before traffic reaches LLDAP.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-traefik-to-lldap-ui
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: lldap
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- port: 17170
|
||||
protocol: TCP
|
||||
---
|
||||
# ── KeyCape → Authelia :9091 ──────────────────────────────────────────────────
|
||||
# KeyCape redirects the browser to Authelia and exchanges auth codes at /token.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-keycape-to-authelia
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: authelia
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: keycape
|
||||
ports:
|
||||
- port: 9091
|
||||
protocol: TCP
|
||||
---
|
||||
# ── KeyCape → LLDAP :3890 ────────────────────────────────────────────────────
|
||||
# KeyCape queries LLDAP for user attributes after authentication.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-keycape-to-lldap
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: lldap
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: keycape
|
||||
ports:
|
||||
- port: 3890
|
||||
protocol: TCP
|
||||
---
|
||||
# ── Authelia → LLDAP :3890 ───────────────────────────────────────────────────
|
||||
# Authelia binds to LLDAP to validate credentials and resolve group membership.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-authelia-to-lldap
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: lldap
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: authelia
|
||||
ports:
|
||||
- port: 3890
|
||||
protocol: TCP
|
||||
---
|
||||
# ── KeyCape egress → Authelia + LLDAP (within sso namespace) ─────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-keycape-egress-internal
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: keycape
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
net-kingdom/component: databases
|
||||
app.kubernetes.io/name: authelia
|
||||
ports:
|
||||
- port: 5432
|
||||
- port: 9091
|
||||
protocol: TCP
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: lldap
|
||||
ports:
|
||||
- port: 3890
|
||||
protocol: TCP
|
||||
---
|
||||
# ── Allow egress to privacyIDEA ──────────────────────────────────────────────
|
||||
# Keycloak calls the privacyIDEA REST API via the privacyIDEA Keycloak Provider.
|
||||
# ── KeyCape egress → privacyIDEA (mfa namespace) :8080 ───────────────────────
|
||||
# KeyCape calls privacyIDEA to check and validate MFA tokens.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-to-privacyidea
|
||||
name: allow-keycape-egress-to-privacyidea
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: keycloak
|
||||
app.kubernetes.io/name: keycape
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
@@ -89,6 +223,27 @@ spec:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
---
|
||||
# ── Authelia egress → LLDAP (within sso namespace) ───────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-authelia-egress-to-lldap
|
||||
namespace: sso
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: authelia
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: lldap
|
||||
ports:
|
||||
- port: 3890
|
||||
protocol: TCP
|
||||
---
|
||||
# ── Allow egress DNS (all pods) ──────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user