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

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

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

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

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

View File

@@ -0,0 +1,93 @@
# T05a — LLDAP (Lightweight LDAP Directory)
LLDAP is the user and group directory for the net-kingdom SSO stack. It provides
LDAP access to Authelia (credential validation) and KeyCape (user attribute lookup).
The admin web UI is IP-restricted and never exposed publicly.
## Prerequisites
- T02 complete (namespaces and NetworkPolicies applied)
- `bootstrap/gen-secrets.sh` run and `secrets/lldap/secrets.env` populated in KeePassXC
- `kubectl` configured with cluster access
## Apply order
```bash
# 1. Generate secrets (if not already done)
cd ../../bootstrap && ./gen-secrets.sh
# 2. Create K8s Secret
cd ../k8s/lldap
chmod +x create-secrets.sh
./create-secrets.sh
# 3. Apply manifests (order matters)
kubectl apply -f pvc.yaml
kubectl apply -f middleware.yaml
kubectl apply -f deployment.yaml
kubectl apply -f ingress.yaml
# 4. Wait for pod to be ready
kubectl rollout status deployment/lldap -n sso --timeout=120s
```
## Post-deploy bootstrap
After the pod is Running, create the two required application groups via the web UI:
```
https://lldap.coulomb.social
Username: admin
Password: LLDAP_LDAP_USER_PASS (from KeePassXC → net-kingdom/LLDAP/admin)
```
Create groups:
- `net-kingdom-users` — standard users
- `net-kingdom-admins` — privileged users (enforce MFA step-up in KeyCape policies)
## Ports
| Port | Protocol | Access | Purpose |
|------|----------|--------|---------|
| 3890 | TCP (LDAP) | Cluster-internal only | Authelia + KeyCape LDAP bind |
| 17170 | TCP (HTTP) | Traefik (IP-restricted) | Admin web UI |
The LDAP port is never exposed via Ingress. Only pods in the `sso` namespace
with `app.kubernetes.io/name=authelia` or `app.kubernetes.io/name=keycape` labels
are allowed to reach port 3890 (enforced by NetworkPolicy).
## Secrets managed
| Secret name | Keys | Purpose |
|-------------|------|---------|
| `lldap-secrets` | `LLDAP_JWT_SECRET`, `LLDAP_LDAP_USER_PASS` | Pod environment variables |
`LLDAP_LDAP_USER_PASS` is the admin bind password shared by Authelia and KeyCape.
It must match the value used in `authelia/create-secrets.sh` and `keycape/create-secrets.sh`.
All three read it from `secrets/lldap/secrets.env`.
## Storage
`lldap-data` PVC (1 Gi, ReadWriteOnce) holds LLDAP's SQLite database.
**Back this PVC up regularly** — it contains all users and groups. If it is lost
without a backup, all user accounts must be re-created and all applications must
be re-enrolled in privacyIDEA.
Optional: switch to PostgreSQL by setting `LLDAP_DATABASE_URL=postgresql://...`
env var in `deployment.yaml` and removing the PVC.
## Verify
```bash
# Check pod status
kubectl get pod -n sso -l app.kubernetes.io/name=lldap
# Check LLDAP health via cluster-internal curl
kubectl run -n sso --rm -it ldap-test --image=busybox --restart=Never \
-- wget -qO- http://lldap.sso.svc.cluster.local:17170/health
# Test LDAP bind (from another pod in the sso namespace)
# ldapwhoami -H ldap://lldap.sso.svc.cluster.local:3890 \
# -D "uid=admin,ou=people,dc=netkingdom,dc=local" -w <LLDAP_LDAP_USER_PASS>
```

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# create-secrets.sh — create the lldap-secrets K8s Secret
#
# Usage:
# ./create-secrets.sh [secrets-dir]
#
# <secrets-dir> is the output directory from sso-mfa/bootstrap/gen-secrets.sh
# (default: ../../bootstrap/secrets).
#
# Creates ONE Secret in the sso namespace:
# lldap-secrets — LLDAP_JWT_SECRET, LLDAP_LDAP_USER_PASS
#
# LLDAP_LDAP_USER_PASS is also used as the LDAP bind password
# by Authelia (authelia/create-secrets.sh) and KeyCape (keycape/create-secrets.sh).
# All three read the same value from secrets/lldap/secrets.env.
set -euo pipefail
SECRETS_DIR="${1:-../../bootstrap/secrets}"
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
if [[ ! -d "$SECRETS_DIR" ]]; then
echo "ERROR: secrets directory not found: $SECRETS_DIR" >&2
echo "Run sso-mfa/bootstrap/gen-secrets.sh first." >&2
exit 1
fi
if [[ ! -f "$LLDAP_ENV" ]]; then
echo "ERROR: $LLDAP_ENV not found" >&2
echo "If you ran gen-secrets.sh before the KeyCape migration, re-run it to add LLDAP secrets." >&2
exit 1
fi
LLDAP_JWT_SECRET=$(bash -c "source '$LLDAP_ENV' 2>/dev/null; echo \$LLDAP_JWT_SECRET")
LLDAP_LDAP_USER_PASS=$(bash -c "source '$LLDAP_ENV' 2>/dev/null; echo \$LLDAP_LDAP_USER_PASS")
if [[ -z "$LLDAP_JWT_SECRET" || -z "$LLDAP_LDAP_USER_PASS" ]]; then
echo "ERROR: could not read LLDAP_JWT_SECRET or LLDAP_LDAP_USER_PASS from $LLDAP_ENV" >&2
exit 1
fi
echo "Creating K8s Secret: lldap-secrets (namespace: sso)"
kubectl create secret generic lldap-secrets \
--namespace=sso \
--from-literal=LLDAP_JWT_SECRET="$LLDAP_JWT_SECRET" \
--from-literal=LLDAP_LDAP_USER_PASS="$LLDAP_LDAP_USER_PASS" \
--dry-run=client -o yaml | kubectl apply -f -
echo ""
echo "Done. Secret lldap-secrets created in namespace: sso"
echo ""
echo "Next:"
echo " Apply manifests (see README.md apply order)."
echo " After LLDAP is Running, create application groups:"
echo " - Log in to https://lldap.coulomb.social with the admin account."
echo " - Create group: net-kingdom-users"
echo " - Create group: net-kingdom-admins"

View File

@@ -0,0 +1,137 @@
# Deployment + Service — LLDAP (namespace: sso)
#
# LLDAP is the lightweight LDAP directory backing both Authelia (credential
# validation) and KeyCape (user attribute lookup). Configured via environment
# variables only; no config file is needed.
#
# Prerequisites:
# 1. pvc.yaml — lldap-data PVC
# 2. create-secrets.sh — lldap-secrets (LLDAP_JWT_SECRET, LLDAP_LDAP_USER_PASS)
# 3. This file
#
# Ports:
# 3890 — LDAP (internal only; Authelia and KeyCape reach LLDAP here)
# 17170 — Web UI (ingress restricted to VPN via middleware — see ingress.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: lldap
namespace: sso
labels:
app.kubernetes.io/name: lldap
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: lldap
strategy:
type: Recreate # single replica; SQLite cannot be accessed concurrently
template:
metadata:
labels:
app.kubernetes.io/name: lldap
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: lldap
# Check https://hub.docker.com/r/lldap/lldap for latest stable tag.
image: lldap/lldap:stable
imagePullPolicy: IfNotPresent
ports:
- name: ldap
containerPort: 3890
protocol: TCP
- name: web-ui
containerPort: 17170
protocol: TCP
env:
- name: LLDAP_LDAP_BASE_DN
value: dc=netkingdom,dc=local
- name: LLDAP_HTTP_HOST
value: "0.0.0.0"
- name: LLDAP_LDAP_HOST
value: "0.0.0.0"
- name: LLDAP_HTTP_PORT
value: "17170"
- name: LLDAP_LDAP_PORT
value: "3890"
# Sensitive values from Secret
- name: LLDAP_JWT_SECRET
valueFrom:
secretKeyRef:
name: lldap-secrets
key: LLDAP_JWT_SECRET
- name: LLDAP_LDAP_USER_PASS
valueFrom:
secretKeyRef:
name: lldap-secrets
key: LLDAP_LDAP_USER_PASS
volumeMounts:
- name: data
mountPath: /data
# LLDAP health check — HTTP endpoint at /health on web UI port
livenessProbe:
httpGet:
path: /health
port: 17170
initialDelaySeconds: 10
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 17170
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
volumes:
- name: data
persistentVolumeClaim:
claimName: lldap-data
---
# Service — ClusterIP; LDAP port for Authelia/KeyCape, Web UI for Traefik.
apiVersion: v1
kind: Service
metadata:
name: lldap
namespace: sso
labels:
app.kubernetes.io/name: lldap
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: lldap
ports:
- name: ldap
port: 3890
targetPort: 3890
protocol: TCP
- name: web-ui
port: 17170
targetPort: 17170
protocol: TCP

View File

@@ -0,0 +1,39 @@
# Ingress — LLDAP web UI (namespace: sso)
#
# lldap.coulomb.social — admin web UI for user/group management
#
# This hostname is VPN/office-only; the lldap-admin-allowlist middleware
# blocks all other source IPs at the Traefik layer.
#
# Config points (see CONFIG.md):
# CP-NK-006 lldap.coulomb.social
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lldap
namespace: sso
labels:
app.kubernetes.io/name: lldap
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.middlewares: "sso-lldap-admin-allowlist@kubernetescrd"
spec:
ingressClassName: traefik
rules:
- host: lldap.coulomb.social
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: lldap
port:
number: 17170
tls:
- secretName: lldap-tls
hosts:
- lldap.coulomb.social

View File

@@ -0,0 +1,25 @@
# Traefik Middleware for LLDAP web UI (namespace: sso)
#
# The LLDAP web UI is admin-only and must never be accessible from the internet.
# This middleware restricts access to VPN/office IPs.
#
# Middleware name referenced in ingress.yaml:
# sso-lldap-admin-allowlist@kubernetescrd
#
# ADJUST sourceRange to your actual VPN / office CIDR(s) before going live.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: lldap-admin-allowlist
namespace: sso
labels:
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
ipAllowList:
# EDIT: replace with your VPN/office CIDRs.
sourceRange:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"

View File

@@ -0,0 +1,21 @@
# PersistentVolumeClaim for LLDAP (namespace: sso)
#
# lldap-data — /data
# Holds: LLDAP's SQLite database (users, groups, keys).
# LLDAP can also be configured to use PostgreSQL
# (set LLDAP_DATABASE_URL=postgresql://...) if you want it on the shared
# CloudNativePG cluster. SQLite on a PVC is sufficient for the lightweight mode.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: lldap-data
namespace: sso
labels:
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi