- Each stack gets its own Vault Transit key (transit/keys/sops-state-<stack>) - state-sync passes per-stack Transit URI + age keys on encrypt - Vault policies scope namespace-owners to their stacks only: - sops-admin: wildcard access to all transit keys - sops-user-<name>: access only to owned stack keys - Anca (plotting-book) can only decrypt plotting-book state - Admin can decrypt everything (via admin Transit policy or age fallback) - External group sops-plotting-book maps Authentik group to Vault policy - Updated CLAUDE.md with state sync documentation
107 lines
3 KiB
Bash
Executable file
107 lines
3 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
STATE_DIR="$REPO_ROOT/state/stacks"
|
|
VAULT_ADDR="${VAULT_ADDR:-https://vault.viktorbarzin.me}"
|
|
|
|
cmd="${1:-help}"
|
|
stack="${2:-}" # optional: operate on single stack
|
|
|
|
# Check if Vault token is valid
|
|
vault_available() {
|
|
VAULT_ADDR="$VAULT_ADDR" vault token lookup &>/dev/null 2>&1
|
|
}
|
|
|
|
# Per-stack Transit key URI
|
|
transit_uri() {
|
|
local stack_name="$1"
|
|
echo "${VAULT_ADDR}/v1/transit/keys/sops-state-${stack_name}"
|
|
}
|
|
|
|
# Extract stack name from directory path
|
|
stack_name_from_dir() {
|
|
basename "$1"
|
|
}
|
|
|
|
# Read age recipients from .sops.yaml
|
|
AGE_RECIPIENTS="$(python3 -c "
|
|
import yaml, sys
|
|
with open('$REPO_ROOT/.sops.yaml') as f: c = yaml.safe_load(f)
|
|
for r in c.get('creation_rules', []):
|
|
age = r.get('age', '')
|
|
if age:
|
|
print(age.replace('\n', '').strip())
|
|
break
|
|
" 2>/dev/null || echo "")"
|
|
|
|
encrypt_state() {
|
|
local dir="$1"
|
|
local src="$dir/terraform.tfstate"
|
|
local dst="$dir/terraform.tfstate.enc"
|
|
local name
|
|
name="$(stack_name_from_dir "$dir")"
|
|
[ -f "$src" ] || return 0
|
|
# Only re-encrypt if state is newer than encrypted version
|
|
if [ ! -f "$dst" ] || [ "$src" -nt "$dst" ]; then
|
|
sops -e --input-type json --output-type json \
|
|
--hc-vault-transit "$(transit_uri "$name")" \
|
|
--age "$AGE_RECIPIENTS" \
|
|
"$src" > "$dst"
|
|
fi
|
|
}
|
|
|
|
decrypt_state() {
|
|
local dir="$1"
|
|
local src="$dir/terraform.tfstate.enc"
|
|
local dst="$dir/terraform.tfstate"
|
|
[ -f "$src" ] || return 0
|
|
|
|
if vault_available; then
|
|
# Vault Transit — per-stack key, no local key needed
|
|
sops -d --input-type json --output-type json "$src" > "$dst"
|
|
elif [ -f "${SOPS_AGE_KEY_FILE:-$HOME/.config/sops/age/keys.txt}" ]; then
|
|
# Fallback: age key on disk (bootstrap / Vault down)
|
|
echo "state-sync: Vault unavailable, falling back to age key" >&2
|
|
SOPS_AGE_KEY_FILE="${SOPS_AGE_KEY_FILE:-$HOME/.config/sops/age/keys.txt}" \
|
|
sops -d --input-type json --output-type json "$src" > "$dst"
|
|
else
|
|
echo "state-sync: ERROR — no Vault token and no age key at ~/.config/sops/age/keys.txt" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
case "$cmd" in
|
|
encrypt)
|
|
if [ -n "$stack" ]; then
|
|
encrypt_state "$STATE_DIR/$stack"
|
|
else
|
|
for dir in "$STATE_DIR"/*/; do
|
|
encrypt_state "$dir"
|
|
done
|
|
fi
|
|
;;
|
|
decrypt)
|
|
if [ -n "$stack" ]; then
|
|
decrypt_state "$STATE_DIR/$stack"
|
|
else
|
|
for dir in "$STATE_DIR"/*/; do
|
|
decrypt_state "$dir"
|
|
done
|
|
fi
|
|
;;
|
|
commit)
|
|
# Encrypt all changed state, then git add + commit
|
|
"$0" encrypt
|
|
cd "$REPO_ROOT"
|
|
git add state/stacks/*/terraform.tfstate.enc
|
|
if ! git diff --cached --quiet; then
|
|
git commit -m "state: update encrypted terraform state"
|
|
fi
|
|
;;
|
|
help)
|
|
echo "Usage: state-sync {encrypt|decrypt|commit} [stack-name]"
|
|
echo "Encrypt uses per-stack Vault Transit key (transit/keys/sops-state-<stack>)."
|
|
echo "Decrypt uses Vault Transit if logged in, falls back to age key."
|
|
;;
|
|
esac
|