StackPractices
intermediate Por Mathias Paulenko

Credenciales en Vivo de Base de Datos con HashiCorp Vault

Como usar HashiCorp Vault para generar credenciales de base de datos de corta duracion, eliminando passwords hardcodeados y reduciendo secret sprawl

Las credenciales de base de datos hardcodeadas en archivos de configuracion son un riesgo persistente de seguridad. HashiCorp Vault resuelve esto generando credenciales gestionadas en vivo que se crean bajo demanda y se revocan automaticamente despues de un TTL configurable.

Cuando Usar Esto

  • Quieres eliminar passwords estaticas de base de datos de la configuracion de aplicaciones
  • La rotacion de credenciales debe ocurrir sin reinicios de aplicacion
  • Necesitas un audit trail de cada acceso a base de datos con atribucion de usuario

Requisitos Previos

  • Servidor Vault ejecutandose (modo dev aceptable para testing)
  • Base de datos PostgreSQL o MySQL
  • Token de Vault con permisos para configurar el database secrets engine

Solucion

1. Habilitar el Database Secrets Engine

vault secrets enable database

2. Configurar Conexion a Base de Datos

vault write database/config/postgres \
  plugin_name=postgresql-database-plugin \
  allowed_roles="app" \
  connection_url="postgresql://{{username}}:{{password}}@localhost:5432/mydb" \
  username="vaultadmin" \
  password="vaultadmin-password"

3. Crear un Rol en Vivo

vault write database/roles/app \
  db_name=postgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
    GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"

4. Solicitar Credenciales en Vivo

// vault-client.ts
import vault from 'node-vault';

const client = vault({ apiVersion: 'v1', endpoint: 'http://localhost:8200' });

export async function getDatabaseCredentials() {
  const result = await client.read('database/creds/app');
  return {
    username: result.data.username,
    password: result.data.password,
    leaseId: result.lease_id,
    leaseDuration: result.lease_duration,
  };
}

5. Integracion de Aplicacion con Renovacion de Lease

// db/ConnectionPool.ts
import { getDatabaseCredentials } from './vault-client';
import { Pool } from 'pg';

class ManagedConnectionPool {
  private pool: Pool | null = null;
  private leaseTimer: NodeJS.Timeout | null = null;

  async initialize() {
    const creds = await getDatabaseCredentials();
    
    this.pool = new Pool({
      host: 'localhost',
      database: 'mydb',
      user: creds.username,
      password: creds.password,
      max: 20,
    });

    // Renueva o rota antes de que expire el lease
    const renewalMs = (creds.leaseDuration - 60) * 1000;
    this.leaseTimer = setTimeout(() => this.rotate(), renewalMs);
  }

  private async rotate() {
    await this.pool?.end();
    await this.initialize();
  }

  async query(sql: string, params: unknown[]) {
    return this.pool!.query(sql, params);
  }

  async close() {
    if (this.leaseTimer) clearTimeout(this.leaseTimer);
    await this.pool?.end();
  }
}

6. Revocar Credenciales al Apagar

// Graceful shutdown handler
process.on('SIGTERM', async () => {
  await connectionPool.close();
  await vault.revoke({ lease_id: currentLeaseId });
  process.exit(0);
});

Como Funciona

  1. Database Plugin se conecta a PostgreSQL con credenciales admin
  2. Role Definition especifica SQL de creacion con username y password templated
  3. Credential Request dispara a Vault para crear un nuevo rol en PostgreSQL
  4. TTL Enforcement elimina automaticamente el rol despues de la expiracion
  5. Lease Renewal extiende o reemplaza credenciales antes de la expiracion

Consideraciones de Produccion

  • Ejecuta Vault en modo HA con almacenamiento Raft para ambientes de produccion
  • Usa AppRole o Kubernetes auth en lugar de tokens de larga duracion
  • Habilita audit devices para loggear cada generacion de credenciales y acceso
  • Configura max_ttl para enforcear duracion maxima de sesion sin importar renovacion

Errores Comunes

  • Olvidar revocar leases, dejando roles huérfanos en base de datos
  • Configurar TTL demasiado corto, causando churn excesivo de credenciales
  • No manejar indisponibilidad de Vault gracefulmente en la aplicacion. Consulta respuesta a incidentes on-call.

Soluciones Avanzadas

Cliente hvac de Python con auth AppRole

import hvac
import os
from typing import TypedDict

class DBCredentials(TypedDict):
    username: str
    password: str
    lease_id: str
    lease_duration: int

class VaultClient:
    """Cliente de Vault con autenticación AppRole y cacheo de credenciales."""

    def __init__(self, vault_addr: str, role_id: str, secret_id: str):
        self.client = hvac.Client(url=vault_addr)
        self._authenticate(role_id, secret_id)
        self._cached_creds: DBCredentials | None = None

    def _authenticate(self, role_id: str, secret_id: str):
        """Autenticar usando AppRole (identidad de máquina)."""
        resp = self.client.auth.approle.login(
            role_id=role_id,
            secret_id=secret_id,
        )
        self.client.token = resp['auth']['client_token']

    def get_db_credentials(self, role: str = 'app') -> DBCredentials:
        """Solicitar credenciales de base de datos de corta duración a Vault."""
        resp = self.client.read(f'database/creds/{role}')
        creds = DBCredentials(
            username=resp['data']['username'],
            password=resp['data']['password'],
            lease_id=resp['lease_id'],
            lease_duration=resp['lease_duration'],
        )
        self._cached_creds = creds
        return creds

    def renew_lease(self, lease_id: str, increment: int = 3600):
        """Renovar un lease antes de que expire."""
        self.client.sys.renew_lease(
            lease_id=lease_id,
            increment=increment,
        )

    def revoke_lease(self, lease_id: str):
        """Revocar credenciales cuando ya no se necesiten."""
        self.client.sys.revoke_lease(lease_id=lease_id)

# Uso
vault = VaultClient(
    vault_addr=os.environ['VAULT_ADDR'],
    role_id=os.environ['VAULT_ROLE_ID'],
    secret_id=os.environ['VAULT_SECRET_ID'],
)

creds = vault.get_db_credentials()
# Usar creds para conectar a PostgreSQL...
# Al apagar:
vault.revoke_lease(creds['lease_id'])

Método de auth Kubernetes

Cuando ejecutas en Kubernetes, usa el backend de auth de Kubernetes para que los pods se autentiquen con su token de service account en lugar de secretos compartidos:

import hvac

def authenticate_kubernetes(vault_addr: str, role: str, jwt_path: str = '/var/run/secrets/kubernetes.io/serviceaccount/token'):
    """Autenticar a Vault usando el token de service account de Kubernetes."""
    client = hvac.Client(url=vault_addr)

    with open(jwt_path, 'r') as f:
        jwt = f.read().strip()

    resp = client.auth.kubernetes.login(
        role=role,
        jwt=jwt,
    )
    client.token = resp['auth']['client_token']
    return client

# Configuración admin de Vault (una vez):
# vault auth enable kubernetes
# vault write auth/kubernetes/config kubernetes_host="https://kubernetes.default.svc"
# vault write auth/kubernetes/role/database-app \
#   bound_service_account_names=app-sa \
#   bound_service_account_namespaces=production \
#   policies=database-access \
#   ttl=1h

Configuración multi-rol con separación lectura/escritura

Crea roles separados de Vault con diferentes privilegios de base de datos para enforcear least-privilege:

# Rol de solo lectura para analytics / reporting
vault write database/roles/app-readonly \
  db_name=postgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
    GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="4h" \
  max_ttl="24h"

# Rol de lectura-escritura para mutaciones de aplicación
vault write database/roles/app-readwrite \
  db_name=postgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="8h"

# Rol de migración con privilegios DDL (TTL corto, solicitud manual)
vault write database/roles/app-migration \
  db_name=postgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
    GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"{{name}}\"; \
    GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="15m" \
  max_ttl="1h"
# Solicitar las credenciales correctas para la tarea
def get_readonly_creds(vault: VaultClient) -> DBCredentials:
    return vault.get_db_credentials(role='app-readonly')

def get_readwrite_creds(vault: VaultClient) -> DBCredentials:
    return vault.get_db_credentials(role='app-readwrite')

def get_migration_creds(vault: VaultClient) -> DBCredentials:
    # TTL corto, siempre revocadas inmediatamente después de la migración
    creds = vault.get_db_credentials(role='app-migration')
    return creds

Cacheo de credenciales con fallback (Node.js)

Cuando Vault no está disponible temporalmente, usa credenciales cacheadas con un warning:

import vault from 'node-vault';
import { Pool } from 'pg';

const client = vault({ apiVersion: 'v1', endpoint: process.env.VAULT_ADDR });

interface CachedCreds {
  username: string;
  password: string;
  leaseId: string;
  leaseDuration: number;
  fetchedAt: number;
}

let cached: CachedCreds | null = null;
const MAX_CACHE_AGE = 2 * 60 * 60 * 1000; // 2 horas

async function getCredsWithFallback(): Promise<CachedCreds> {
  try {
    const result = await client.read('database/creds/app');
    cached = {
      username: result.data.username,
      password: result.data.password,
      leaseId: result.lease_id,
      leaseDuration: result.lease_duration,
      fetchedAt: Date.now(),
    };
    return cached;
  } catch (err) {
    if (cached && Date.now() - cached.fetchedAt < MAX_CACHE_AGE) {
      console.warn('Vault no disponible, usando credenciales cacheadas', {
        age: Date.now() - cached.fetchedAt,
        leaseId: cached.leaseId,
      });
      return cached;
    }
    throw new Error('Vault no disponible y no hay credenciales cacheadas válidas');
  }
}

Preguntas frecuentes

¿Esta solución está lista para producción?

Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.

¿Cuáles son las características de rendimiento?

El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.

¿Cómo depuro problemas con este enfoque?

Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.