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

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

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

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

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

View File

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

View File

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

View File

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

View 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 .
```

View 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

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

View 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

View 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

View 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

View 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
```

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

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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
```

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

View 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

View 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

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

View 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

View File

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

View File

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