#!/usr/bin/env bash # k8s/talos-iam/setup_vault.sh # Deploys HashiCorp Vault into the iam namespace, initialises it, and wires # the JWT auth backend to Authentik so the talos-cli secrets subcommand works. # # Prerequisites: # - Authentik already running in the iam namespace (run setup_talos_iam.sh first) # - kubectl configured (KUBECONFIG → cluster-config/kubeconfig) # - helm >= 3.x # - vault CLI installed locally (https://developer.hashicorp.com/vault/downloads) # - talos-iam/.env containing (see .env.example): # MINIO_ROOT_USER=... # MINIO_ROOT_PASSWORD=... # The script seeds initial secrets and needs read access to MinIO credentials. # # What this script does (in order): # 1. Create vault-minio-creds K8s Secret (MinIO creds for the S3 backend) # 2. Helm install hashicorp/vault # 3. Wait for vault pod to be Running # 4. vault operator init → capture unseal keys + root token # 5. vault operator unseal (3 of 5 key shares) # 6. Login with root token # 7. Enable KV v2 secret engine at secret/ # 8. Enable JWT auth backend, configure with Authentik JWKS # 9. Write policies (shell-secrets, mcp-readonly, cluster-admin) # 10. Create JWT roles (shell, mcp, cluster) # 11. Seed initial secrets from .env # 12. Register talos-cli-shell OIDC app in Authentik via API # 13. Print next steps set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" KUBECONFIG="${KUBECONFIG:-${REPO_ROOT}/cluster-config/kubeconfig}" export KUBECONFIG NAMESPACE=storage RELEASE=vault AUTHENTIK_URL="http://authentik-server.iam.svc.cluster.local" VAULT_ADDR_CLUSTER="http://vault.storage.svc.cluster.local:8200" # Local access via port-forward during setup VAULT_PORT_FWD_ADDR="http://127.0.0.1:8200" UNSEAL_KEYS_FILE="$HOME/.vault-data/.vault-init.json" # KEEP OFFLINE after setup # ── Failure surfacing ───────────────────────────────────────────────────────── CURRENT_STEP="init" on_err() { local rc=$? echo "[ERROR] step '${CURRENT_STEP}' failed (exit ${rc} at line ${BASH_LINENO[0]})" >&2 exit "${rc}" } trap on_err ERR step() { CURRENT_STEP="$1"; echo ""; echo "==> $2"; } # ── Load credentials ────────────────────────────────────────────────────────── step load_env "Loading credentials from talos-iam/.env..." if [[ -f "$HOME/.authentik/.env" ]]; then # shellcheck source=/dev/null source "$HOME/.authentik/.env" fi for var in MINIO_ROOT_USER MINIO_ROOT_PASSWORD AUTHENTIK_BOOTSTRAP_TOKEN; do if [[ -z "${!var:-}" ]]; then echo "ERROR: ${var} is not set — export it or add it to talos-iam/.env" >&2 exit 1 fi done # ── 1. MinIO credentials secret for Vault S3 backend ───────────────────────── step minio_secret "Creating vault-minio-creds secret..." kubectl create secret generic vault-minio-creds \ --namespace="${NAMESPACE}" \ --from-literal=access_key="${MINIO_ROOT_USER}" \ --from-literal=secret_key="${MINIO_ROOT_PASSWORD}" \ --dry-run=client -o yaml | kubectl apply -f - # ── 1b. Placeholder vault-unseal-keys secret (required before Helm install) ─── # extraSecretEnvironmentVars references vault-unseal-keys at pod start time, # but the real keys only exist after vault operator init. Create empty placeholders # so the pod starts; step vault_unseal_secret overwrites them with real values. step vault_unseal_placeholder "Creating placeholder vault-unseal-keys secret..." kubectl create secret generic vault-unseal-keys \ --namespace="${NAMESPACE}" \ --from-literal=key1="" \ --from-literal=key2="" \ --from-literal=key3="" \ --dry-run=client -o yaml | kubectl apply -f - # ── 1c. Register talos-cli-shell OIDC app in Authentik ─────────────────────── # Must exist before Helm install so Vault can validate the JWKS URL at jwt/config time. step authentik_app "Registering talos-cli-shell OIDC provider in Authentik..." AUTHENTIK_API="http://127.0.0.1:7000/api/v3" kubectl port-forward -n "${NAMESPACE}" svc/authentik-server 7000:80 & AK_PF_PID=$! trap 'kill ${AK_PF_PID} 2>/dev/null' EXIT sleep 3 # Fetch required flow PKs from Authentik _auth_flow_pk=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API}/flows/instances/?slug=default-provider-authorization-implicit-consent" \ | jq -r '.results[0].pk // empty') _inval_flow_pk=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API}/flows/instances/?slug=default-provider-invalidation-flow" \ | jq -r '.results[0].pk // empty') if [[ -z "${_auth_flow_pk}" || -z "${_inval_flow_pk}" ]]; then echo "ERROR: required Authentik flows not found — is provision_oidc.py already run?" >&2 exit 1 fi _signing_key_pk=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API}/crypto/certificatekeypairs/?has_key=true&page_size=1" \ | jq -r '.results[0].pk // empty') if [[ -z "${_signing_key_pk}" ]]; then echo "ERROR: no signing key found in Authentik — create one under System → Certificates" >&2 exit 1 fi _provider_payload=$(jq -n \ --arg auth_flow "${_auth_flow_pk}" \ --arg inval_flow "${_inval_flow_pk}" \ --arg signing_key "${_signing_key_pk}" \ '{ name: "talos-cli-shell", client_type: "confidential", grant_types: ["client_credentials"], token_validity: "minutes=5", sub_mode: "hashed_user_id", include_claims_in_id_token: true, audience: "vault", authorization_flow: $auth_flow, invalidation_flow: $inval_flow, signing_key: $signing_key, redirect_uris: [] }') _existing_provider=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API}/providers/oauth2/?name=talos-cli-shell" | jq -r '.results[0].pk // empty') if [[ -n "${_existing_provider}" ]]; then echo "Provider talos-cli-shell exists (pk=${_existing_provider}) — patching config..." PROVIDER_PK="${_existing_provider}" curl -sf -X PATCH "${AUTHENTIK_API}/providers/oauth2/${PROVIDER_PK}/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d "${_provider_payload}" > /dev/null CLIENT_SECRET="(unchanged — retrieve from Authentik admin UI if needed)" else PROVIDER_PK=$(curl -sf -X POST "${AUTHENTIK_API}/providers/oauth2/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d "${_provider_payload}" | jq -r '.pk') CLIENT_SECRET=$(curl -sf -X POST \ "${AUTHENTIK_API}/providers/oauth2/${PROVIDER_PK}/set_secret/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d '{}' | jq -r '.client_secret') echo "Created provider pk=${PROVIDER_PK}" echo "" echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ Authentik OIDC app registered. │" echo "│ Client ID: talos-cli-shell │" echo "│ Client Secret: ${CLIENT_SECRET} │" echo "│ Store this secret — it will not be shown again. │" echo "└─────────────────────────────────────────────────────────────────┘" fi # Idempotent: ensure the application exists and is bound to the provider. # Runs on both create and patch paths so re-runs always produce a consistent state. _existing_app=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API}/core/applications/?slug=talos-cli-shell" | jq -r '.results[0].pk // empty') if [[ -n "${_existing_app}" ]]; then echo "Application talos-cli-shell exists (pk=${_existing_app}) — ensuring provider binding..." curl -sf -X PATCH "${AUTHENTIK_API}/core/applications/${_existing_app}/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"provider\": ${PROVIDER_PK}}" > /dev/null else curl -sf -X POST "${AUTHENTIK_API}/core/applications/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(jq -n --argjson pk "${PROVIDER_PK}" \ '{"name":"talos-cli-shell","slug":"talos-cli-shell","provider":$pk}')" > /dev/null echo "Application talos-cli-shell created and bound to provider pk=${PROVIDER_PK}" fi kill "${AK_PF_PID}" 2>/dev/null || true unset AK_PF_PID # ── 1d. Register vault-browser OIDC app in Authentik (browser / UI login) ──── # Separate from talos-cli-shell: this uses authorization_code grant so humans # can log in via the Vault UI or `vault login -method=oidc`. step authentik_vault_browser "Registering vault-browser OIDC provider in Authentik..." AUTHENTIK_API_BROWSER="http://127.0.0.1:7000/api/v3" kubectl port-forward -n iam svc/authentik-server 7000:80 & AK_PF2_PID=$! trap 'kill ${AK_PF2_PID} 2>/dev/null' EXIT sleep 3 _auth_flow_pk2=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API_BROWSER}/flows/instances/?slug=default-provider-authorization-implicit-consent" \ | jq -r '.results[0].pk // empty') _inval_flow_pk2=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API_BROWSER}/flows/instances/?slug=default-provider-invalidation-flow" \ | jq -r '.results[0].pk // empty') _signing_key_pk2=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API_BROWSER}/crypto/certificatekeypairs/?has_key=true&page_size=1" \ | jq -r '.results[0].pk // empty') _vault_browser_payload=$(jq -n \ --arg auth_flow "${_auth_flow_pk2}" \ --arg inval_flow "${_inval_flow_pk2}" \ --arg signing_key "${_signing_key_pk2}" \ '{ name: "vault-browser", client_type: "confidential", client_id: "vault-browser", grant_types: ["authorization_code", "refresh"], token_validity: "hours=8", sub_mode: "hashed_user_id", include_claims_in_id_token: true, authorization_flow: $auth_flow, invalidation_flow: $inval_flow, signing_key: $signing_key, redirect_uris: [ {"matching_mode": "strict", "url": "http://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"}, {"matching_mode": "strict", "url": "http://localhost:8250/oidc/callback"} ] }') _existing_vault_browser=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API_BROWSER}/providers/oauth2/?name=vault-browser" | jq -r '.results[0].pk // empty') if [[ -n "${_existing_vault_browser}" ]]; then echo "Provider vault-browser exists (pk=${_existing_vault_browser}) — patching..." VAULT_BROWSER_PK="${_existing_vault_browser}" curl -sf -X PATCH "${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d "${_vault_browser_payload}" > /dev/null # Re-fetch the client secret from the detail endpoint (list responses mask it) VAULT_BROWSER_CLIENT_SECRET=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/" \ | jq -r '.client_secret') else VAULT_BROWSER_PK=$(curl -sf -X POST "${AUTHENTIK_API_BROWSER}/providers/oauth2/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d "${_vault_browser_payload}" | jq -r '.pk') VAULT_BROWSER_CLIENT_SECRET=$(curl -sf -X POST \ "${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/set_secret/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d '{}' | jq -r '.client_secret') echo "Created vault-browser provider pk=${VAULT_BROWSER_PK}" fi # Bind application _existing_vault_app=$(curl -sf \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ "${AUTHENTIK_API_BROWSER}/core/applications/?slug=vault-browser" | jq -r '.results[0].pk // empty') if [[ -n "${_existing_vault_app}" ]]; then curl -sf -X PATCH "${AUTHENTIK_API_BROWSER}/core/applications/${_existing_vault_app}/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"provider\": ${VAULT_BROWSER_PK}}" > /dev/null echo "Application vault-browser updated." else curl -sf -X POST "${AUTHENTIK_API_BROWSER}/core/applications/" \ -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(jq -n --argjson pk "${VAULT_BROWSER_PK}" \ '{"name":"vault-browser","slug":"vault-browser","provider":$pk}')" > /dev/null echo "Application vault-browser created." fi kill "${AK_PF2_PID}" 2>/dev/null || true unset AK_PF2_PID # ── 2. Ensure vault bucket exists in MinIO ──────────────────────────────────── step minio_bucket "Ensuring 'vault' bucket exists in MinIO..." kubectl run vault-bucket-init --rm -i --restart=Never \ --namespace=storage \ --image=minio/mc:latest \ --env="MC_HOST_local=http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@minio.storage.svc.cluster.local:9000" \ --command -- mc mb --ignore-existing local/vault # ── 3. Helm install Vault ───────────────────────────────────────────────────── step helm_install "Installing HashiCorp Vault via Helm..." helm repo add hashicorp https://helm.releases.hashicorp.com helm repo update hashicorp helm upgrade --install "${RELEASE}" hashicorp/vault \ --namespace "${NAMESPACE}" \ --values "${SCRIPT_DIR}/vault-values.yaml" \ --wait --timeout=120s # ── 4. Wait for vault pod ───────────────────────────────────────────────────── step wait_pod "Waiting for vault-0 pod to be Running..." kubectl wait pod/vault-0 \ --namespace="${NAMESPACE}" \ --for=condition=Ready=false \ --timeout=60s 2>/dev/null || true # pod starts uninitialized (not Ready) — that's expected # Give it a moment to bind the port sleep 5 # ── 5. Port-forward for init/unseal ────────────────────────────────────────── step port_forward "Starting port-forward to vault-0 on 127.0.0.1:8200..." kubectl port-forward -n "${NAMESPACE}" pod/vault-0 8200:8200 & PF_PID=$! trap 'kill ${PF_PID} 2>/dev/null; on_err' ERR trap 'kill ${PF_PID} 2>/dev/null' EXIT sleep 3 export VAULT_ADDR="${VAULT_PORT_FWD_ADDR}" # ── 6. Init ─────────────────────────────────────────────────────────────────── step vault_init "Initialising Vault (5 key shares, threshold 3)..." if vault status 2>/dev/null | grep -q "Initialized.*true"; then echo "Vault is already initialised — skipping init." else vault operator init \ -key-shares=5 \ -key-threshold=3 \ -format=json > "${UNSEAL_KEYS_FILE}" chmod 600 "${UNSEAL_KEYS_FILE}" echo "" echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ IMPORTANT: unseal keys + root token saved to: │" echo "│ ${UNSEAL_KEYS_FILE}" echo "│ Move this file OFFLINE (USB / password manager) immediately. │" echo "└─────────────────────────────────────────────────────────────────┘" fi # ── 6b. Store unseal keys in K8s Secret for auto-unseal on restart ─────────── step vault_unseal_secret "Storing 3 unseal keys in vault-unseal-keys Secret..." kubectl create secret generic vault-unseal-keys \ --namespace="${NAMESPACE}" \ --from-literal=key1="$(jq -r '.unseal_keys_b64[0]' "${UNSEAL_KEYS_FILE}")" \ --from-literal=key2="$(jq -r '.unseal_keys_b64[1]' "${UNSEAL_KEYS_FILE}")" \ --from-literal=key3="$(jq -r '.unseal_keys_b64[2]' "${UNSEAL_KEYS_FILE}")" \ --dry-run=client -o yaml | kubectl apply -f - # ── 7. Unseal ───────────────────────────────────────────────────────────────── step vault_unseal "Unsealing Vault (3 of 5 shares)..." if vault status 2>/dev/null | grep -q "Sealed.*false"; then echo "Vault is already unsealed — skipping." else for i in 0 1 2; do KEY=$(jq -r ".unseal_keys_b64[${i}]" "${UNSEAL_KEYS_FILE}") vault operator unseal "${KEY}" done fi # ── 8. Login with root token ────────────────────────────────────────────────── step vault_login "Logging in with root token..." ROOT_TOKEN=$(jq -r ".root_token" "${UNSEAL_KEYS_FILE}") vault login "${ROOT_TOKEN}" # ── 9. Enable KV v2 ────────────────────────────────────────────────────────── step kv_enable "Enabling KV v2 at secret/..." vault secrets enable -path=secret kv-v2 2>/dev/null || echo "KV v2 already enabled." # ── 10. Enable JWT auth backend ─────────────────────────────────────────────── step jwt_enable "Enabling JWT auth backend..." vault auth enable jwt 2>/dev/null || echo "JWT auth already enabled." JWKS_URL="${AUTHENTIK_URL}/application/o/talos-cli-shell/jwks/" vault write auth/jwt/config \ jwks_url="${JWKS_URL}" \ default_role="shell" echo "JWT auth configured with JWKS at: ${JWKS_URL}" # ── 11. Write policies ──────────────────────────────────────────────────────── step policies "Writing Vault policies..." vault policy write shell-secrets - <<'EOF' # shell-secrets: read MCP keys and cloud tokens for interactive shell sessions path "secret/data/mcp/*" { capabilities = ["read"] } path "secret/data/cloud/*" { capabilities = ["read"] } EOF vault policy write mcp-readonly - <<'EOF' # mcp-readonly: each MCP server reads only its own path path "secret/data/mcp/{{identity.entity.aliases.*.metadata.client_id}}" { capabilities = ["read"] } EOF vault policy write cluster-admin - <<'EOF' # cluster-admin: read cluster service credentials (MinIO, Grafana, etc.) path "secret/data/cluster/*" { capabilities = ["read", "update"] } path "secret/data/mcp/*" { capabilities = ["read", "create", "update", "delete"] } path "secret/data/cloud/*" { capabilities = ["read", "create", "update", "delete"] } EOF # ── 12. Create JWT roles ────────────────────────────────────────────────────── step jwt_roles "Creating JWT roles..." # shell role: interactive shell sessions via talos-cli vault write auth/jwt/role/shell \ role_type=jwt \ bound_audiences="vault" \ user_claim="sub" \ policies="shell-secrets" \ ttl=4h \ max_ttl=8h # mcp role: MCP server processes (short TTL, non-renewable) vault write auth/jwt/role/mcp \ role_type=jwt \ bound_audiences="vault" \ user_claim="sub" \ policies="mcp-readonly" \ ttl=1h \ max_ttl=1h # cluster role: admin operations (bootstrap scripts, rotation jobs) vault write auth/jwt/role/cluster - <<'EOF' { "role_type": "jwt", "bound_audiences": ["vault"], "user_claim": "sub", "bound_claims": {"groups": ["homelab-admins"]}, "token_policies": ["cluster-admin"], "token_ttl": "1h" } EOF # ── 13. Enable OIDC auth (browser / UI login via Authentik) ────────────────── step oidc_enable "Enabling OIDC auth method for browser login..." vault auth enable oidc 2>/dev/null || echo "OIDC auth already enabled." vault write auth/oidc/config \ oidc_discovery_url="http://authentik.riotpiao.com/application/o/vault-browser/" \ oidc_client_id="vault-browser" \ oidc_client_secret="${VAULT_BROWSER_CLIENT_SECRET}" \ default_role="homelab" # homelab role: all authenticated Authentik users get shell-secrets + cluster-admin. # Restrict further by adding bound_claims once group-based mapping is configured. vault write auth/oidc/role/homelab \ role_type=oidc \ bound_audiences="vault-browser" \ allowed_redirect_uris="http://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback,http://localhost:8250/oidc/callback" \ user_claim="sub" \ oidc_scopes="openid,profile,email" \ token_policies="shell-secrets,cluster-admin" \ token_ttl=8h \ token_max_ttl=12h echo "OIDC auth configured." echo " Browser login: http://vault.riotpiao.com → sign in with Authentik" echo " CLI login: VAULT_ADDR=http://vault.riotpiao.com vault login -method=oidc" # ── 14. Seed initial secrets ────────────────────────────────────────────────── step seed_secrets "Seeding initial secrets from environment (accessible after OIDC login)..." # Cluster service credentials vault kv put secret/cluster/minio \ user="${MINIO_ROOT_USER}" \ password="${MINIO_ROOT_PASSWORD}" if [[ -n "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then vault kv put secret/cluster/grafana \ password="${GRAFANA_ADMIN_PASSWORD}" fi if [[ -n "${DUCKDNS_TOKEN:-}" ]]; then vault kv put secret/cloud/duckdns \ token="${DUCKDNS_TOKEN}" fi # MCP / AI tooling keys (optional — add when available) if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then vault kv put secret/mcp/anthropic api_key="${ANTHROPIC_API_KEY}" fi if [[ -n "${GITHUB_TOKEN:-}" ]]; then vault kv put secret/mcp/github token="${GITHUB_TOKEN}" fi # ── Done ────────────────────────────────────────────────────────────────────── echo "" echo "==> Vault bootstrap complete." echo "" echo "Next steps:" echo " 1. Move ${UNSEAL_KEYS_FILE} OFFLINE (USB / password manager)." echo " 2. Browser login: open http://vault.riotpiao.com → choose OIDC → sign in with Authentik" echo " 3. CLI login: export VAULT_ADDR=http://vault.riotpiao.com" echo " vault login -method=oidc" echo " 4. Read MinIO credentials from Vault after login:" echo " vault kv get secret/cluster/minio" echo " 5. talos-cli (JWT): talos secrets login && talos secrets status"