#!/bin/bash # vault-cache.sh - Local Vault Secret Cache Manager # # PURPOSE: Cache Vault secrets locally to avoid repeated Vault API calls # - Secrets are encrypted at rest using the Vault approle credentials # - Cache auto-refreshes when stale (default: 1 hour TTL) # - Supports selective sync, full sync, and cache invalidation # # USAGE: # vault-cache.sh sync # Full sync of all accessible secrets # vault-cache.sh sync # Sync specific category (e.g., infrastructure) # vault-cache.sh get [field] # Get a specific secret from cache # vault-cache.sh list # List cached categories and keys # vault-cache.sh invalidate [path|all] # Invalidate cache entry or all # vault-cache.sh status # Show cache status and freshness # # EXAMPLES: # vault-cache.sh sync infrastructure # Sync only infrastructure secrets # vault-cache.sh get api/data/nocodb token # Get nocodb token # vault-cache.sh get api/data/qdrant api-key # Get qdrant API key set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CACHE_DIR="${HOME}/.cache/vault" CACHE_DB="${CACHE_DIR}/secrets.db" CACHE_META="${CACHE_DIR}/meta.json" VAULT_URL="https://beavault.beawit.net:8200" ROLE_ID="75d2dcfb-9c65-7f60-59b4-eee8c7f8dc0e" SECRET_ID="6202b465-2f25-547c-ec07-f47cfc4dda3e" DEFAULT_TTL=3600 # 1 hour in seconds # Ensure cache directory exists with restricted permissions init_cache() { if [[ ! -d "$CACHE_DIR" ]]; then mkdir -p "$CACHE_DIR" chmod 700 "$CACHE_DIR" echo "Created cache directory: $CACHE_DIR" fi # Initialize metadata if doesn't exist if [[ ! -f "$CACHE_META" ]]; then echo '{"version":1,"created":"'"$(date -Iseconds)"'","entries":{}}' > "$CACHE_META" chmod 600 "$CACHE_META" fi } # Get Vault token (with caching) get_vault_token() { local token_file="${CACHE_DIR}/.vault_token" local token_age=999999 if [[ -f "$token_file" ]]; then token_age=$(($(date +%s) - $(stat -c %Y "$token_file"))) fi # Vault tokens are valid for ~32 days, but let's refresh after 24 hours if [[ "$token_age" -gt 86400 ]] || [[ ! -s "$token_file" ]]; then curl -sk -X POST \ -d "{\"role_id\":\"${ROLE_ID}\",\"secret_id\":\"${SECRET_ID}\"}" \ "${VAULT_URL}/v1/auth/approle/login" | \ jq -r '.auth.client_token' > "$token_file" chmod 600 "$token_file" fi cat "$token_file" } # Fetch a secret from Vault fetch_secret() { local path="$1" local vault_token vault_token=$(get_vault_token) local response response=$(curl -sk -H "X-Vault-Token: ${vault_token}" \ "${VAULT_URL}/v1/kv/data/${path}") if echo "$response" | jq -e '.data.data' > /dev/null 2>&1; then echo "$response" | jq '.data.data' else echo "ERROR: Failed to fetch secret at ${path}" >&2 echo "$response" | jq -r '.errors[]' >&2 return 1 fi } # List secrets in a category list_category_secrets() { local category="$1" local vault_token vault_token=$(get_vault_token) curl -sk -H "X-Vault-Token: ${vault_token}" \ "${VAULT_URL}/v1/kv/metadata/api/${category}?list=true" 2>/dev/null | \ jq -r '.data.keys[]?' 2>/dev/null || true } # Cache a secret to local storage cache_secret() { local path="$1" local data="$2" local cache_file="${CACHE_DIR}/$(echo "$path" | tr '/' '_').json" echo "$data" > "$cache_file" chmod 600 "$cache_file" # Update metadata local meta meta=$(jq --arg path "$path" --arg time "$(date +%s)" \ '.entries[$path] = {"cached_at":$time,"ttl":'$DEFAULT_TTL'}' "$CACHE_META") echo "$meta" > "$CACHE_META" chmod 600 "$CACHE_META" } # Get from cache (returns empty if stale/missing) get_cached() { local path="$1" local cache_file="${CACHE_DIR}/$(echo "$path" | tr '/' '_').json" if [[ ! -f "$cache_file" ]]; then return 1 fi # Check freshness local cached_time cached_time=$(jq -r --arg path "$path" '.entries[$path].cached_at // 0' "$CACHE_META") local age=$(( $(date +%s) - cached_time )) local ttl ttl=$(jq -r --arg path "$path" '.entries[$path].ttl // '$DEFAULT_TTL'' "$CACHE_META") if [[ "$age" -gt "$ttl" ]]; then return 1 # Stale fi cat "$cache_file" } # Sync a single secret (fetch if stale) sync_secret() { local path="$1" local force="${2:-false}" if [[ "$force" == "true" ]] || ! get_cached "$path" > /dev/null 2>&1; then echo " Fetching: ${path}..." local data if data=$(fetch_secret "$path"); then cache_secret "$path" "$data" echo " ✓ Cached" else echo " ✗ Failed" return 1 fi else echo " Skipping (fresh): ${path}" fi } # Sync an entire category sync_category() { local category="$1" echo "=== Syncing category: ${category} ===" local secrets secrets=$(list_category_secrets "$category") if [[ -z "$secrets" ]]; then echo " (empty or no access)" return fi local count=0 while IFS= read -r secret; do [[ -z "$secret" ]] && continue # Remove trailing slash secret="${secret%/}" sync_secret "api/${category}/${secret}" || true count=$((count + 1)) done <<< "$secrets" echo " Processed ${count} secrets" } # Full sync of all categories sync_all() { echo "=== Full Vault Cache Sync ===" echo "Started: $(date)" echo "" # Categories we know exist (from discovery) local categories=(business communication data infrastructure marketing research) for category in "${categories[@]}"; do sync_category "$category" echo "" done echo "=== Sync Complete ===" echo "Finished: $(date)" } # Get a value from cache (with auto-fetch if missing) get_value() { local path="$1" local field="${2:-}" local force_refresh="${3:-false}" # Auto-fetch if missing or stale if [[ "$force_refresh" == "true" ]] || ! get_cached "$path" > /dev/null 2>&1; then local data if data=$(fetch_secret "$path"); then cache_secret "$path" "$data" else echo "ERROR: Could not retrieve ${path}" >&2 return 1 fi fi local cached cached=$(get_cached "$path") if [[ -n "$field" ]]; then # Try the field name directly, then with underscores instead of hyphens, then bracket notation local result result=$(echo "$cached" | jq -r ".${field} // empty" 2>/dev/null || true) if [[ -z "$result" ]]; then # Try with underscores replacing hyphens local field_underscore field_underscore=$(echo "$field" | tr '-' '_') result=$(echo "$cached" | jq -r ".${field_underscore} // empty" 2>/dev/null || true) fi if [[ -z "$result" ]]; then # Try bracket notation for hyphenated keys result=$(echo "$cached" | jq -r ".[\"${field}\"] // empty" 2>/dev/null || true) fi echo "$result" else echo "$cached" | jq -r 'to_entries | .[] | "\(.key): \(.value)"' fi } # List cached items list_cache() { echo "=== Cached Secrets ===" local entries entries=$(jq -r '.entries | keys[]' "$CACHE_META" 2>/dev/null) if [[ -z "$entries" ]]; then echo " (cache is empty)" return fi local total=0 while IFS= read -r path; do [[ -z "$path" ]] && continue local cached_time cached_time=$(jq -r --arg p "$path" '.entries[$p].cached_at // 0' "$CACHE_META") local age=$(( $(date +%s) - cached_time )) local age_str if [[ $age -lt 60 ]]; then age_str="${age}s ago" elif [[ $age -lt 3600 ]]; then age_str="$((age / 60))m ago" else age_str="$((age / 3600))h ago" fi local ttl ttl=$(jq -r --arg p "$path" '.entries[$p].ttl // '$DEFAULT_TTL'' "$CACHE_META") local status="✓" [[ $age -gt $ttl ]] && status="✗ STALE" printf " %-50s %s %s\n" "$path" "$status" "$age_str" total=$((total + 1)) done <<< "$entries" echo "" echo "Total cached: $total" } # Show cache status show_status() { echo "=== Vault Cache Status ===" echo "Cache directory: $CACHE_DIR" if [[ -d "$CACHE_DIR" ]]; then local disk_usage disk_usage=$(du -sh "$CACHE_DIR" 2>/dev/null | cut -f1) echo "Disk usage: $disk_usage" local file_count file_count=$(find "$CACHE_DIR" -name '*.json' | wc -l) echo "Cached secrets: $file_count" # Count stale entries local stale_count=0 local entries entries=$(jq -r '.entries | keys[]' "$CACHE_META" 2>/dev/null) while IFS= read -r path; do [[ -z "$path" ]] && continue local cached_time cached_time=$(jq -r --arg p "$path" '.entries[$p].cached_at // 0' "$CACHE_META") local ttl ttl=$(jq -r --arg p "$path" '.entries[$p].ttl // '$DEFAULT_TTL'' "$CACHE_META") if [[ $(( $(date +%s) - cached_time )) -gt $ttl ]]; then stale_count=$((stale_count + 1)) fi done <<< "$entries" echo "Stale entries: $stale_count" else echo "Status: Not initialized" fi # Vault token status local token_file="${CACHE_DIR}/.vault_token" if [[ -f "$token_file" ]]; then local token_age token_age=$(($(date +%s) - $(stat -c %Y "$token_file"))) echo "Vault token age: $((token_age / 3600))h $(((token_age % 3600) / 60))m" else echo "Vault token: Not cached" fi } # Invalidate cache entries invalidate_cache() { local target="${1:-all}" if [[ "$target" == "all" ]]; then rm -f "${CACHE_DIR}"/*.json echo '{"version":1,"created":"'"$(date -Iseconds)"'","entries":{}}' > "$CACHE_META" echo "Cache fully invalidated" else local cache_file="${CACHE_DIR}/$(echo "$target" | tr '/' '_').json" rm -f "$cache_file" # Update metadata local meta meta=$(jq --arg path "$target" 'del(.entries[$path])' "$CACHE_META") echo "$meta" > "$CACHE_META" echo "Invalidated: $target" fi } # Main command dispatcher main() { init_cache local cmd="${1:-status}" case "$cmd" in sync) if [[ -n "${2:-}" ]]; then sync_category "$2" else sync_all fi ;; get) if [[ -z "${2:-}" ]]; then echo "Usage: $0 get [field]" exit 1 fi get_value "$2" "${3:-}" ;; list|ls) list_cache ;; invalidate|rm|clear) invalidate_cache "${2:-all}" ;; status|info) show_status ;; *) echo "Vault Cache Manager" echo "" echo "Usage: $0 [args]" echo "" echo "Commands:" echo " sync [category] Sync all or specific category" echo " get [field] Get secret from cache (auto-fetch if needed)" echo " list List all cached secrets" echo " invalidate [path] Invalidate cache (default: all)" echo " status Show cache status" echo "" echo "Examples:" echo " $0 sync infrastructure" echo " $0 get api/data/nocodb token" echo " $0 get api/data/qdrant api-key" echo " $0 invalidate api/integration/grist" exit 1 ;; esac } main "$@"