diff --git a/CONFIG.md b/CONFIG.md index 823eafd..1e0567c 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -23,8 +23,9 @@ If yes to any of the above, don't add it here. | CP-NK-001 | ACME contact email | `bernd.worsch+netkingdom@gmail.com` | `sso-mfa/k8s/cert-manager/issuers.yaml:38` | | CP-NK-002 | privacyIDEA portal hostname | `pink.coulomb.social` | `sso-mfa/k8s/privacyidea/ingress.yaml` | | CP-NK-003 | privacyIDEA self-service hostname | `pink-account.coulomb.social` | `sso-mfa/k8s/privacyidea/ingress.yaml` | -| CP-NK-004 | Keycloak SSO hostname | `kc.coulomb.social` | `sso-mfa/k8s/keycloak/deployment.yaml`, `sso-mfa/k8s/keycloak/ingress.yaml` | -| CP-NK-005 | privacyIDEA Keycloak Provider JAR URL | *(not set — edit before apply)* | `sso-mfa/k8s/keycloak/deployment.yaml` | +| CP-NK-004 | KeyCape OIDC hostname | `kc.coulomb.social` | `sso-mfa/k8s/keycape/ingress.yaml`, `sso-mfa/k8s/authelia/configmap.yaml`, `sso-mfa/k8s/keycape/create-secrets.sh` | +| CP-NK-005 | Authelia login portal hostname | `auth.coulomb.social` | `sso-mfa/k8s/authelia/ingress.yaml`, `sso-mfa/k8s/authelia/configmap.yaml` | +| CP-NK-006 | LLDAP admin web UI hostname | `lldap.coulomb.social` | `sso-mfa/k8s/lldap/ingress.yaml` | --- @@ -88,45 +89,58 @@ the entire cluster. --- -## CP-NK-004 — Keycloak SSO hostname +## CP-NK-004 — KeyCape OIDC hostname **Value:** `kc.coulomb.social` **Set:** 2026-03-19 **Set by:** worsch **Location(s):** -- `sso-mfa/k8s/keycloak/deployment.yaml` — `KC_HOSTNAME` env var -- `sso-mfa/k8s/keycloak/ingress.yaml` — both Ingress `host` fields +- `sso-mfa/k8s/keycape/ingress.yaml` — Ingress `host` field +- `sso-mfa/k8s/authelia/configmap.yaml` — `redirect_uris` for the KeyCape OIDC client +- `sso-mfa/k8s/keycape/create-secrets.sh` — `issuer` and `redirectURI` in config.yaml -**Why non-default:** Subdomain prefix must be chosen by the operator. `kc` = -**K**ey**c**loak, consistent with the service-initial naming pattern. +**Why non-default:** Subdomain prefix must be chosen by the operator. `kc` is retained +from the original design (`kc` = **K**ey**C**ape) for DNS stability. -**Scope:** TLS certificate, Traefik routing, Keycloak's internal hostname strictness -check, and all OIDC/SAML redirect URIs registered in this realm. Changing this -hostname after clients are registered requires updating all registered redirect URIs. +**Scope:** TLS certificate, Traefik routing, KeyCape's OIDC issuer claim, and all +redirect URIs registered by downstream applications. Changing this hostname after +clients are registered requires updating all registered `redirect_uris`. --- -## CP-NK-005 — privacyIDEA Keycloak Provider JAR URL +## CP-NK-005 — Authelia login portal hostname -**Value:** *(not set — operator must edit before applying T05)* -**Set:** — -**Set by:** — +**Value:** `auth.coulomb.social` +**Set:** 2026-03-19 +**Set by:** worsch **Location(s):** -- `sso-mfa/k8s/keycloak/deployment.yaml` — `PROVIDER_JAR_URL` env var in the - `install-privacyidea-provider` init container +- `sso-mfa/k8s/authelia/ingress.yaml` — Ingress `host` field +- `sso-mfa/k8s/authelia/configmap.yaml` — `session.domain` parent domain comment -**Why non-default:** The JAR URL depends on the chosen release version, which must -be verified for compatibility with the deployed Keycloak image version. There is no -stable "latest" URL suitable for automation. +**Why non-default:** Subdomain prefix must be chosen by the operator. `auth` is the +conventional prefix for authentication portals. -**How to set:** -1. Browse https://github.com/privacyIDEA/keycloak-provider/releases -2. Choose a release compatible with the Keycloak image version in `deployment.yaml`. -3. Edit `deployment.yaml`: replace `EDIT_BEFORE_APPLY` with the `.jar` download URL. -4. Update this entry with the chosen URL and version. +**Scope:** TLS certificate, Traefik routing, and the Authelia login page that users' +browsers are redirected to during the OIDC flow. The session cookie `domain` is set +to the parent domain (`coulomb.social`) so the cookie is valid across both +`auth.coulomb.social` and `kc.coulomb.social`. -**Scope:** Keycloak init container only. If switching to a custom Keycloak image -(see T05 README "Custom image" section), this config point becomes obsolete and -can be removed. +--- + +## CP-NK-006 — LLDAP admin web UI hostname + +**Value:** `lldap.coulomb.social` +**Set:** 2026-03-19 +**Set by:** worsch + +**Location(s):** +- `sso-mfa/k8s/lldap/ingress.yaml` — Ingress `host` field + +**Why non-default:** Subdomain prefix must be chosen by the operator. + +**Scope:** TLS certificate and Traefik routing for the LLDAP admin web UI. Access +is IP-restricted by the `lldap-admin-allowlist` Traefik Middleware (VPN/office +CIDRs only). The LDAP port (3890) is cluster-internal only and never exposed +via Ingress. diff --git a/sso-mfa/WORKPLAN.md b/sso-mfa/WORKPLAN.md new file mode 100644 index 0000000..7d058c7 --- /dev/null +++ b/sso-mfa/WORKPLAN.md @@ -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 diff --git a/sso-mfa/bootstrap/gen-secrets.sh b/sso-mfa/bootstrap/gen-secrets.sh index a3c3f1d..64bb959 100755 --- a/sso-mfa/bootstrap/gen-secrets.sh +++ b/sso-mfa/bootstrap/gen-secrets.sh @@ -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" < "$OUT_DIR/keycloak/secrets.env" < "$OUT_DIR/lldap/secrets.env" < "$OUT_DIR/authelia/secrets.env" < "$OUT_DIR/keycape/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}…" diff --git a/sso-mfa/k8s/README.md b/sso-mfa/k8s/README.md index 4194088..5cdfd96 100644 --- a/sso-mfa/k8s/README.md +++ b/sso-mfa/k8s/README.md @@ -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 diff --git a/sso-mfa/k8s/authelia/README.md b/sso-mfa/k8s/authelia/README.md new file mode 100644 index 0000000..317bad1 --- /dev/null +++ b/sso-mfa/k8s/authelia/README.md @@ -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 . +``` diff --git a/sso-mfa/k8s/authelia/configmap.yaml b/sso-mfa/k8s/authelia/configmap.yaml new file mode 100644 index 0000000..e369fc3 --- /dev/null +++ b/sso-mfa/k8s/authelia/configmap.yaml @@ -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 diff --git a/sso-mfa/k8s/authelia/create-secrets.sh b/sso-mfa/k8s/authelia/create-secrets.sh new file mode 100644 index 0000000..9be7fa8 --- /dev/null +++ b/sso-mfa/k8s/authelia/create-secrets.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# create-secrets.sh — create the authelia-secrets K8s Secret +# +# Usage: +# ./create-secrets.sh [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." diff --git a/sso-mfa/k8s/authelia/deployment.yaml b/sso-mfa/k8s/authelia/deployment.yaml new file mode 100644 index 0000000..80d78f7 --- /dev/null +++ b/sso-mfa/k8s/authelia/deployment.yaml @@ -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 diff --git a/sso-mfa/k8s/authelia/ingress.yaml b/sso-mfa/k8s/authelia/ingress.yaml new file mode 100644 index 0000000..a4beb99 --- /dev/null +++ b/sso-mfa/k8s/authelia/ingress.yaml @@ -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 diff --git a/sso-mfa/k8s/authelia/pvc.yaml b/sso-mfa/k8s/authelia/pvc.yaml new file mode 100644 index 0000000..c321301 --- /dev/null +++ b/sso-mfa/k8s/authelia/pvc.yaml @@ -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 diff --git a/sso-mfa/k8s/keycape/README.md b/sso-mfa/k8s/keycape/README.md new file mode 100644 index 0000000..736067f --- /dev/null +++ b/sso-mfa/k8s/keycape/README.md @@ -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 /keycape:v0.1 . +docker push /keycape:v0.1 + +# Update the image field in deployment.yaml: +# image: /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 +``` diff --git a/sso-mfa/k8s/keycape/create-pi-token.sh b/sso-mfa/k8s/keycape/create-pi-token.sh new file mode 100644 index 0000000..1cb6f2a --- /dev/null +++ b/sso-mfa/k8s/keycape/create-pi-token.sh @@ -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" diff --git a/sso-mfa/k8s/keycape/create-secrets.sh b/sso-mfa/k8s/keycape/create-secrets.sh new file mode 100644 index 0000000..4f7a2d6 --- /dev/null +++ b/sso-mfa/k8s/keycape/create-secrets.sh @@ -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 </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 diff --git a/sso-mfa/k8s/keycape/ingress.yaml b/sso-mfa/k8s/keycape/ingress.yaml new file mode 100644 index 0000000..bb2f0dc --- /dev/null +++ b/sso-mfa/k8s/keycape/ingress.yaml @@ -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 diff --git a/sso-mfa/k8s/keycape/middleware.yaml b/sso-mfa/k8s/keycape/middleware.yaml new file mode 100644 index 0000000..759570b --- /dev/null +++ b/sso-mfa/k8s/keycape/middleware.yaml @@ -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 diff --git a/sso-mfa/k8s/keycloak/README.md b/sso-mfa/k8s/keycloak/README.md deleted file mode 100644 index 55a1f93..0000000 --- a/sso-mfa/k8s/keycloak/README.md +++ /dev/null @@ -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- 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 | | `sso-mfa/k8s/keycloak/deployment.yaml` | -``` - ---- - -## Disaster recovery - -If the `keycloak-data` PVC is lost (build cache only — all state is in PostgreSQL): - -1. Create a new PVC with `pvc.yaml`. -2. Restart the deployment — Keycloak will rebuild from PostgreSQL on first start. - Allow 3–5 minutes for the rebuild + DB reconnect. -3. Realm config, clients, and users are preserved in the database. - -If the PostgreSQL `keycloak_db` database is lost, restore from the CNPG backup -(T03 procedure) before restarting Keycloak. diff --git a/sso-mfa/k8s/keycloak/bootstrap-realm.sh b/sso-mfa/k8s/keycloak/bootstrap-realm.sh deleted file mode 100755 index 17854ec..0000000 --- a/sso-mfa/k8s/keycloak/bootstrap-realm.sh +++ /dev/null @@ -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." diff --git a/sso-mfa/k8s/keycloak/create-secrets.sh b/sso-mfa/k8s/keycloak/create-secrets.sh deleted file mode 100755 index dcba020..0000000 --- a/sso-mfa/k8s/keycloak/create-secrets.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# create-secrets.sh — create the keycloak-config K8s Secret -# -# Usage: -# ./create-secrets.sh [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" diff --git a/sso-mfa/k8s/keycloak/deployment.yaml b/sso-mfa/k8s/keycloak/deployment.yaml deleted file mode 100644 index 7bdc856..0000000 --- a/sso-mfa/k8s/keycloak/deployment.yaml +++ /dev/null @@ -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 diff --git a/sso-mfa/k8s/keycloak/ingress.yaml b/sso-mfa/k8s/keycloak/ingress.yaml deleted file mode 100644 index b211867..0000000 --- a/sso-mfa/k8s/keycloak/ingress.yaml +++ /dev/null @@ -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 diff --git a/sso-mfa/k8s/keycloak/middleware.yaml b/sso-mfa/k8s/keycloak/middleware.yaml deleted file mode 100644 index f8c3ee7..0000000 --- a/sso-mfa/k8s/keycloak/middleware.yaml +++ /dev/null @@ -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 diff --git a/sso-mfa/k8s/keycloak/pvc.yaml b/sso-mfa/k8s/keycloak/pvc.yaml deleted file mode 100644 index dc8aa06..0000000 --- a/sso-mfa/k8s/keycloak/pvc.yaml +++ /dev/null @@ -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 diff --git a/sso-mfa/k8s/lldap/README.md b/sso-mfa/k8s/lldap/README.md new file mode 100644 index 0000000..10b18f6 --- /dev/null +++ b/sso-mfa/k8s/lldap/README.md @@ -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 +``` diff --git a/sso-mfa/k8s/lldap/create-secrets.sh b/sso-mfa/k8s/lldap/create-secrets.sh new file mode 100644 index 0000000..46d0b71 --- /dev/null +++ b/sso-mfa/k8s/lldap/create-secrets.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# create-secrets.sh — create the lldap-secrets K8s Secret +# +# Usage: +# ./create-secrets.sh [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" diff --git a/sso-mfa/k8s/lldap/deployment.yaml b/sso-mfa/k8s/lldap/deployment.yaml new file mode 100644 index 0000000..d694511 --- /dev/null +++ b/sso-mfa/k8s/lldap/deployment.yaml @@ -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 diff --git a/sso-mfa/k8s/lldap/ingress.yaml b/sso-mfa/k8s/lldap/ingress.yaml new file mode 100644 index 0000000..6deb856 --- /dev/null +++ b/sso-mfa/k8s/lldap/ingress.yaml @@ -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 diff --git a/sso-mfa/k8s/lldap/middleware.yaml b/sso-mfa/k8s/lldap/middleware.yaml new file mode 100644 index 0000000..925d6b9 --- /dev/null +++ b/sso-mfa/k8s/lldap/middleware.yaml @@ -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" diff --git a/sso-mfa/k8s/lldap/pvc.yaml b/sso-mfa/k8s/lldap/pvc.yaml new file mode 100644 index 0000000..346c187 --- /dev/null +++ b/sso-mfa/k8s/lldap/pvc.yaml @@ -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 diff --git a/sso-mfa/k8s/network-policies/netpol-sso.yaml b/sso-mfa/k8s/network-policies/netpol-sso.yaml index 10deb63..30b5408 100644 --- a/sso-mfa/k8s/network-policies/netpol-sso.yaml +++ b/sso-mfa/k8s/network-policies/netpol-sso.yaml @@ -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 diff --git a/sso-mfa/k8s/verify-t05.sh b/sso-mfa/k8s/verify-t05.sh index 4c67dad..da3a833 100755 --- a/sso-mfa/k8s/verify-t05.sh +++ b/sso-mfa/k8s/verify-t05.sh @@ -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