top of page

Rotación automatizada de contraseñas para Aurora PostgreSQL mediante Secrets Manager y una Lambda personalizada

10 sept
7 min de lectura

Introducción

Gestionar las credenciales de las bases de datos es uno de los aspectos más críticos —y, a menudo, más descuidados— de la seguridad en la nube. Las contraseñas estáticas que nunca cambian generan una gran superficie de ataque: una única credencial filtrada puede permitir que un atacante mantenga acceso persistente a los datos de producción.

AWS Secrets Manager aborda este problema gestionando el ciclo de vida completo de las credenciales de las bases de datos: las almacena de forma cifrada, las distribuye a las aplicaciones y las rota automáticamente según una programación definida. Al combinarlo con Amazon Aurora PostgreSQL y una función de Lambda que implementa el protocolo de rotación, todo el proceso puede ejecutarse sin intervención manual.


En este artículo se muestra cómo implementar la rotación automática de contraseñas para usuarios de Aurora PostgreSQL utilizando AWS Secrets Manager y una función Lambda nativa, aprovisionando toda la infraestructura mediante Terraform.


¿Por qué no utilizar la aplicación de SAR?

AWS ofrece una función Lambda de rotación preconfigurada a través de Serverless Application Repository (SecretsManagerPostgreSQLRotationMultiUser). Sin embargo, para implementarla se requieren los permisos: serverlessrepo:GetApplication y serverlessrepo:CreateCloudFormationChangeSet sobre un recurso administrado por AWS. Estos permisos suelen no estar incluidos en los Permission Sets de SSO, incluso para roles con permisos de Administrador, ya que se aplican a un recurso ubicado en otra cuenta de AWS.


En lugar de solicitar permisos de SAR entre cuentas, implementar la función Lambda directamente como un recurso nativo resulta más sencillo y permite tener un control total sobre el código.


Arquitectura

El flujo de rotación funciona de la siguiente manera:


  1. Secrets Manager activa la función Lambda según la programación establecida (cada 90 días en este ejemplo).

  2. La función Lambda ejecuta el protocolo de rotación de 4 pasos.

  3. Cada paso lee o escribe el secreto en la versión identificada como AWSPENDING o AWSCURRENT.

  4. Una vez completado el proceso correctamente, las nuevas credenciales pasan a ser AWSCURRENT y quedan disponibles de inmediato para las aplicaciones.


workflow-example

Requisitos previos:

  • Un clúster de Amazon Aurora PostgreSQL existente (o RDS PostgreSQL; ver la nota al final).

  • El clúster debe ser accesible desde dentro de la VPC.

  • Terraform >= 1.0 y el proveedor de AWS >= 5.0.

  • Python 3.13 instalado localmente (requerido por terraform-aws-modules/lambda/aws para empaquetar las dependencias).

  • Los usuarios de la base de datos que se quieran rotar deben existir previamente en la base de datos.


Estructura del proyecto

project/
├── aurora.tf
├── lambda.tf
└── lambdas/
    └── aurora-rotation/
        ├── index.py
        └── requirements.txt

Paso 1 — Grupo de seguridad para la Lambda

La función Lambda encargada de la rotación necesita acceso saliente a Aurora en el puerto 5432 y a Secrets Manager en el puerto 443:


# aurora.tf
resource "aws_security_group" "rotation_lambda" {
  name        = "project-environment-rotation-lambda"
  description = "Security group for the Aurora password rotation Lambda"
  vpc_id      = vpc_id

  egress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"] # adjust to your VPC CIDR
    description = "Allow outbound to Aurora"
  }

  egress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow outbound to Secrets Manager endpoint"
  }
}

# Allow inbound from the Lambda SG into Aurora
resource "aws_security_group_rule" "aurora_from_rotation_lambda" {
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  security_group_id        = aurora_security_group_id
  source_security_group_id = aws_security_group.rotation_lambda.id
  description              = "Allow PostgreSQL traffic from rotation Lambda"
}

Paso 2 — Definir los secretos

Cada usuario de la base de datos tiene su propio secreto. La regla de ciclo de vida ignore_changes evita que Terraform sobrescriba el valor después de que la función Lambda lo haya rotado:


resource "aws_secretsmanager_secret" "dev_user_secret" {
  name                    = "/${local.project}/development/DB_PASSWORD"
  description             = "Credentials for dev_user on Aurora PostgreSQL"
  recovery_window_in_days = 0
})
resource "aws_secretsmanager_secret_version" "dev_user_secret_val" {
  secret_id = aws_secretsmanager_secret.dev_user_secret.id
  secret_string = jsonencode({
    engine   = "postgres"
    host     = module.aurora.cluster_endpoint
    port     = 5432
    dbname   = "dev_db"
    username = "dev_user"
    password = var.DEV_DB_PASSWORD
  })

  lifecycle {
    # Prevent Terraform from overwriting after Lambda rotates
    ignore_changes = [secret_string]
  }
}

Paso 3 — Rol de IAM para la Lambda

# lambda.tf

resource "aws_iam_role" "aurora_rotation_lambda" {
  name = "${local.project}-${local.environment}-aurora-rotation"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "aurora_rotation_vpc_execution" {
  role       = aws_iam_role.aurora_rotation_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}

resource "aws_iam_policy" "aurora_rotation_secrets" {
  name        = "${local.project}-${local.environment}-aurora-rotation-secrets"
  description = "Allow rotation Lambda to manage Secrets Manager secrets"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "secretsmanager:DescribeSecret",
          "secretsmanager:GetSecretValue",
          "secretsmanager:PutSecretValue",
          "secretsmanager:UpdateSecretVersionStage",
          "secretsmanager:GetRandomPassword",
        ]
        Resource = "*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "aurora_rotation_secrets" {
  role       = aws_iam_role.aurora_rotation_lambda.name
  policy_arn = aws_iam_policy.aurora_rotation_secrets.arn
}

Paso 4 — Implementar la función Lambda de rotación

El módulo terraform-aws-modules/lambda/aws se encarga del empaquetado, incluida la instalación de pg8000 (un driver de PostgreSQL desarrollado completamente en Python, que no requiere compilación nativa):


# lambda.tf (continued)

module "aurora_rotation_lambda" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "8.8.0"

  function_name = "${local.project}-${local.environment}-aurora-rotation"
  handler       = "index.lambda_handler"
  runtime       = "python3.13"
  timeout       = 30

  create_role = false
  lambda_role = aws_iam_role.aurora_rotation_lambda.arn

  source_path = "../../../lambdas/aurora-rotation"

  # pg8000 is a pure-Python PostgreSQL driver — no native compilation needed
  layers = []

  environment_variables = {
    SECRETS_MANAGER_ENDPOINT = "https://secretsmanager.${data.aws_region.current.name}.amazonaws.com"
  }

  vpc_subnet_ids         = module.vpc.private_subnets
  vpc_security_group_ids = [aws_security_group.lambda_rotation_sg.id]

  allowed_triggers = {
    SecretsManagerDev = {
      principal  = "secretsmanager.amazonaws.com"
      source_arn = aws_secretsmanager_secret.dev_user_secret.arn
    }
    SecretsManagerQa = {
      principal  = "secretsmanager.amazonaws.com"
      source_arn = aws_secretsmanager_secret.qa_user_secret.arn
    }
  }

  # Avoid adding permissions to a specific version — use $LATEST alias
  create_current_version_allowed_triggers = false

  cloudwatch_logs_retention_in_days = local.cloudwatch_log_group_retention_in_days
}

Nota: El valor de runtime debe coincidir con la versión de Python instalada localmente. El módulo ejecuta pip install utilizando ese intérprete para empaquetar las dependencias. Si tenés python3.11, configurá runtime = "python3.11".


Paso 5 — Configurar la programación de rotación

resource "aws_secretsmanager_secret_rotation" "dev_user_rotation" {

  secret_id           = aws_secretsmanager_secret.dev_user_secret.id
  rotation_lambda_arn = module.aurora_rotation_lambda.lambda_function_arn

  rotation_rules {
    automatically_after_days = 90
  }
}

Paso 6 — La función de rotación

Creá lambdas/aurora-rotation/requirements.txt:

pg8000==1.31.2

Creá lambdas/aurora-rotation/index.py:

"""
Aurora PostgreSQL single-user secret rotation for AWS Secrets Manager.

The Lambda connects using the secret's own credentials and changes its
own password. No masterarn required.

Rotation steps
--------------
1. createSecret  – generate a new random password and stage it as AWSPENDING.
2. setSecret     – connect to Aurora as the user and ALTER its own password.
3. testSecret    – verify the pending credentials can open a connection.
4. finishSecret  – promote AWSPENDING → AWSCURRENT.
"""

import json
import logging
import os

import boto3
import pg8000.native

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    secret_arn = event["SecretId"]
    token      = event["ClientRequestToken"]
    step       = event["Step"]

    sm = boto3.client(
        "secretsmanager",
        endpoint_url=os.environ.get("SECRETS_MANAGER_ENDPOINT"),
    )

    metadata = sm.describe_secret(SecretId=secret_arn)
    if not metadata.get("RotationEnabled"):
        raise ValueError(f"Rotation is not enabled for secret {secret_arn}")

    versions = metadata.get("VersionIdsToStages", {})
    if token not in versions:
        raise ValueError(f"Token {token} not found in versions of {secret_arn}")
    if "AWSCURRENT" in versions[token]:
        logger.info("Token is already AWSCURRENT — nothing to do")
        return
    if "AWSPENDING" not in versions[token]:
        raise ValueError(f"Token {token} is not staged as AWSPENDING for {secret_arn}")

    if step == "createSecret":
        _create_secret(sm, secret_arn, token)
    elif step == "setSecret":
        _set_secret(sm, secret_arn, token)
    elif step == "testSecret":
        _test_secret(sm, secret_arn, token)
    elif step == "finishSecret":
        _finish_secret(sm, secret_arn, token)
    else:
        raise ValueError(f"Unknown rotation step: {step}")


def _create_secret(sm, arn, token):
    """Stage a new random password as AWSPENDING (idempotent)."""
    try:
        sm.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING")
        logger.info("createSecret: AWSPENDING already exists — skipping")
        return
    except sm.exceptions.ResourceNotFoundException:
        pass

    current = _get_secret_dict(sm, arn, stage="AWSCURRENT")
    new_password = sm.get_random_password(
        PasswordLength=32,
        ExcludeCharacters="/@\"'\\",
    )["RandomPassword"]

    current["password"] = new_password
    sm.put_secret_value(
        SecretId=arn,
        ClientRequestToken=token,
        SecretString=json.dumps(current),
        VersionStages=["AWSPENDING"],
    )
    logger.info("createSecret: new AWSPENDING version staged")


def _set_secret(sm, arn, token):
    """Connect as the user itself and ALTER its own password."""
    pending = _get_secret_dict(sm, arn, version_id=token, stage="AWSPENDING")

    # Idempotency: if pending creds already work, password was already changed
    if _can_connect(pending):
        logger.info("setSecret: pending credentials already work — skipping ALTER")
        return

    # Connect using current credentials
    current = _get_secret_dict(sm, arn, stage="AWSCURRENT")
    conn = _connect(current)
    try:
        username     = pending["username"]
        new_password = pending["password"]
        # PostgreSQL does not support bind parameters in DDL statements.
        # Fetch a properly escaped literal from the server to prevent injection.
        escaped = conn.run("SELECT quote_literal(:pwd)", pwd=new_password)[0][0]
        conn.run(f'ALTER USER "{username}" WITH PASSWORD {escaped}')
        logger.info("setSecret: password updated successfully")
    finally:
        conn.close()


def _test_secret(sm, arn, token):
    """Verify the AWSPENDING credentials can connect to Aurora."""
    pending = _get_secret_dict(sm, arn, version_id=token, stage="AWSPENDING")
    conn = _connect(pending)
    conn.close()
    logger.info("testSecret: connection with AWSPENDING credentials succeeded")


def _finish_secret(sm, arn, token):
    """Promote AWSPENDING to AWSCURRENT."""
    metadata = sm.describe_secret(SecretId=arn)
    current_version = next(
        (v for v, stages in metadata["VersionIdsToStages"].items() if "AWSCURRENT" in stages),
        None,
    )
    if current_version == token:
        logger.info("finishSecret: token is already AWSCURRENT — nothing to do")
        return

    sm.update_secret_version_stage(
        SecretId=arn,
        VersionStage="AWSCURRENT",
        MoveToVersionId=token,
        RemoveFromVersionId=current_version,
    )
    logger.info("finishSecret: AWSCURRENT promoted to token %s", token)


def _get_secret_dict(sm, arn, *, stage, version_id=None):
    kwargs = {"SecretId": arn, "VersionStage": stage}
    if version_id:
        kwargs["VersionId"] = version_id
    raw = sm.get_secret_value(**kwargs)["SecretString"]
    return json.loads(raw)


def _connect(secret_dict):
    return pg8000.native.Connection(
        user=secret_dict["username"],
        password=secret_dict["password"],
        host=secret_dict["host"],
        port=int(secret_dict.get("port", 5432)),
        database=secret_dict.get("dbname", "postgres"),
        ssl_context=True,
        timeout=5,
    )


def _can_connect(secret_dict):
    try:
        conn = _connect(secret_dict)
        conn.close()
        return True
    except Exception:
        return False

Paso 7 — Ejemplo de tfvars

DEV_DB_PASSWORD = Example123

Nota de seguridad: Nunca hagas commit de initial_password en el repositorio. Pasalo mediante una variable de entorno (TF_VAR_db_users) o utilizá un backend de secretos como AWS SSM Parameter Store.


Paso 8 — Probar la rotación

Forzá una rotación inmediata para validar la configuración:

aws secretsmanager rotate-secret \
  --secret-id "/myapp/staging/APP_DB_PASSWORD" \
  --region us-east-1

Seguí los registros de la función Lambda en tiempo real:

aws logs tail /aws/lambda/myapp-staging-aurora-rotation \
  --region us-east-1 \
  --follow

Una rotación exitosa genera cuatro entradas de registro secuenciales:

createSecret: new AWSPENDING version staged
setSecret: password updated successfully
testSecret: connection with AWSPENDING credentials succeeded
finishSecret: AWSCURRENT promoted to token <version-id>

Confirmá que la rotación se haya completado:

aws secretsmanager describe-secret \
  --secret-id "/myapp/staging/APP_DB_PASSWORD" \
  --region us-east-1 \
  --query '{LastRotatedDate: LastRotatedDate, RotationEnabled: RotationEnabled}'


Conclusión

Esta configuración elimina por completo el uso de contraseñas estáticas para las bases de datos. Una vez implementada, las credenciales se rotan según la programación establecida, sin necesidad de intervención manual. Las aplicaciones siempre obtienen el valor actual desde Secrets Manager al momento de establecer la conexión, por lo que la rotación es transparente para ellas.


Las principales ventajas de utilizar un enfoque basado en una Lambda nativa frente a SAR son:


  • No requiere permisos entre cuentas.

  • Se integra naturalmente con los flujos de trabajo existentes de Terraform.

  • Ofrece visibilidad total sobre la lógica de rotación, lo que facilita la depuración, auditoría y ampliación de la solución.


Nota: Si bien esta implementación utiliza Python, AWS Lambda admite varios otros lenguajes para las funciones de rotación, entre ellos Rust (recomendado por AWS), Node.js (JavaScript/TypeScript), Java, Go, Ruby, C#/.NET y PowerShell.


Referencias






Tomas Köhler

Cloud Engineer

 
 
bottom of page
window.addEventListener('load', function() {   var search = window.location.search;   if (!search || search === '?') return;   var params = search.slice(1);   setTimeout(function() {     document.querySelectorAll('iframe').forEach(function(fr) {       try { fr.contentWindow.postMessage({type:'TERACLOUD_UTM', params: params}, '*'); } catch(e) {}     });   }, 1500); });