Módulo 7: Detective Vs Preventive Guardrails
5. Manos a la obra: CloudTrail, hasta donde llega LocalStack
Descripción
Esta lección resuelve TM-03 — Repudiation, la fila de THREAT-MODEL.md abierta desde el Módulo 1: "No CloudTrail trail exists". Declara el trail real de Andes Cargo en HCL, lo valida con el motor real de Terraform, y intenta de verdad —no solo describe— generar un evento de prueba y leerlo de vuelta con awslocal cloudtrail lookup-events. El resultado de ese intento, documentado tal cual salió al escribir esta lección, es la parte más honesta de todo este módulo: no una promesa de que "esto funcionaría", sino la evidencia real de hasta dónde llega este laboratorio $0 específico.
Conexión con el módulo
La lección 4 nombró las tres cámaras. Esta lección instala la primera — la única de las tres con cobertura de API confirmada en la propia documentación de LocalStack. El trail que declaras aquí es, además, el prerequisito técnico de la lección 7: la alarma sobre un evento de IAM sensible depende de que un trail esté entregando eventos a EventBridge.
Paso 1 — El bucket que recibe los logs, con su política
Un trail de CloudTrail necesita un bucket S3 propio, con una política que le dé permiso explícito de escribir en él — CloudTrail nunca escribe en un bucket sin esa política, ni siquiera en la propia cuenta que lo creó. En un archivo nuevo, cloudtrail.tf:
resource "aws_s3_bucket" "cloudtrail_logs" {
bucket = "andes-cargo-cloudtrail-logs"
tags = local.common_tags
}
data "aws_iam_policy_document" "cloudtrail_bucket_policy" {
statement {
sid = "AWSCloudTrailAclCheck"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
actions = ["s3:GetBucketAcl"]
resources = ["arn:aws:s3:::andes-cargo-cloudtrail-logs"]
}
statement {
sid = "AWSCloudTrailWrite"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::andes-cargo-cloudtrail-logs/AWSLogs/000000000000/*"]
condition {
test = "StringEquals"
variable = "s3:x-amz-acl"
values = ["bucket-owner-full-control"]
}
}
}
resource "aws_s3_bucket_policy" "cloudtrail_logs" {
bucket = aws_s3_bucket.cloudtrail_logs.id
policy = data.aws_iam_policy_document.cloudtrail_bucket_policy.json
}
Dos statements, cada uno con un propósito documentado por AWS: AWSCloudTrailAclCheck le permite a CloudTrail confirmar el ACL del bucket antes de escribir (una verificación previa, no una escritura); AWSCloudTrailWrite le permite escribir objetos, acotado al prefijo AWSLogs/000000000000/* —el patrón fijo que CloudTrail usa siempre, con el ID de cuenta como parte de la ruta— y condicionado a que cada objeto se escriba con el ACL bucket-owner-full-control, para que Andes Cargo, no CloudTrail, sea el dueño efectivo de cada log que se genera. Fíjate en algo que ya reconoces del Módulo 2: andes-cargo-cloudtrail-logs es un bucket nuevo, dedicado exclusivamente a logs de auditoría — nunca reutiliza andes-cargo-shipment-docs, el bucket de negocio, exactamente la misma separación de responsabilidades que ya viste entre secretos (M3) y datos de negocio.
Paso 2 — El trail mismo
resource "aws_cloudtrail" "andes_cargo" {
name = "andes-cargo-trail"
s3_bucket_name = aws_s3_bucket.cloudtrail_logs.id
include_global_service_events = true
is_multi_region_trail = false
enable_logging = true
tags = local.common_tags
depends_on = [aws_s3_bucket_policy.cloudtrail_logs]
}
Tres decisiones, cada una explicada:
include_global_service_events = true— IAM es un servicio global, no regional (una llamada aiam:AttachRolePolicyno "vive" enus-east-1más que en cualquier otra región). Sin este flag, un trail regional nunca registraría los eventos de IAM que la lección 7 necesita capturar — este flag es, con precisión, lo que hace que un evento de IAM llegue al trail.is_multi_region_trail = false— Andes Cargo opera en una sola región (us-east-1, fijo desdeaws-core-services-guide), así que un trail multi-región agregaría cobertura que este caso específico no necesita. Contra una cuenta AWS real que operara en más de una región,truesería la elección correcta.depends_on = [aws_s3_bucket_policy.cloudtrail_logs]— una dependencia explícita, no implícita. Terraform no puede inferir, solo mirando los argumentos del recursoaws_cloudtrail, que necesita esperar a que la política del bucket exista —s3_bucket_namesolo referencia el bucket, no su política. Sin estedepends_on, existiría una condición de carrera real: CloudTrail podría intentar escribir en el bucket antes de que la política que se lo permite esté aplicada.
Paso 3 — fmt, validate: el motor real
terraform fmt -recursive -check
terraform validate
Qué esperar (literal, ejecutado para escribir esta lección):
Success! The configuration is valid.
Paso 4 — El plan: el trail y su bucket, reales
terraform plan
Qué esperar (literal, ejecutado para escribir esta lección — se muestra la parte de este plan que corresponde a cloudtrail.tf; el plan completo de este punto de la guía incluye, además, el boundary de la lección 3 y las piezas de la lección 7):
# aws_cloudtrail.andes_cargo will be created
+ resource "aws_cloudtrail" "andes_cargo" {
+ arn = (known after apply)
+ enable_log_file_validation = false
+ enable_logging = true
+ home_region = (known after apply)
+ id = (known after apply)
+ include_global_service_events = true
+ is_multi_region_trail = false
+ is_organization_trail = false
+ name = "andes-cargo-trail"
+ region = "us-east-1"
+ s3_bucket_name = (known after apply)
+ sns_topic_arn = (known after apply)
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ tags_all = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
}
# aws_s3_bucket.cloudtrail_logs will be created
+ resource "aws_s3_bucket" "cloudtrail_logs" {
+ arn = (known after apply)
+ bucket = "andes-cargo-cloudtrail-logs"
+ bucket_domain_name = (known after apply)
+ id = (known after apply)
+ region = "us-east-1"
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ tags_all = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
... (otros atributos "known after apply", omitidos por brevedad)
}
# aws_s3_bucket_policy.cloudtrail_logs will be created
+ resource "aws_s3_bucket_policy" "cloudtrail_logs" {
+ bucket = (known after apply)
+ id = (known after apply)
+ policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "s3:GetBucketAcl"
+ Effect = "Allow"
+ Principal = {
+ Service = "cloudtrail.amazonaws.com"
}
+ Resource = "arn:aws:s3:::andes-cargo-cloudtrail-logs"
+ Sid = "AWSCloudTrailAclCheck"
},
+ {
+ Action = "s3:PutObject"
+ Condition = {
+ StringEquals = {
+ "s3:x-amz-acl" = "bucket-owner-full-control"
}
}
+ Effect = "Allow"
+ Principal = {
+ Service = "cloudtrail.amazonaws.com"
}
+ Resource = "arn:aws:s3:::andes-cargo-cloudtrail-logs/AWSLogs/000000000000/*"
+ Sid = "AWSCloudTrailWrite"
},
]
+ Version = "2012-10-17"
}
)
+ region = "us-east-1"
}
Fíjate en s3_bucket_name = (known after apply) dentro de aws_cloudtrail.andes_cargo, no el nombre literal — el mismo mecanismo de dependencia entre recursos nuevos del mismo apply que ya explicaron el Módulo 2, lección 5, y la lección 3 de este módulo, ahora sobre un tercer par de recursos.
Paso 5 — El intento honesto: un evento de prueba, y leerlo de vuelta
Esto es lo que hace a esta lección distinta de la mayoría de las anteriores: en vez de describir de antemano qué comando "funcionaría", esta sección corrió de verdad, en el momento de escribir esta lección, contra este entorno específico —sin LOCALSTACK_AUTH_TOKEN exportado, sin ningún contenedor de LocalStack corriendo—. El resultado que sigue es el que produjo, literalmente, intentar generar y leer un evento real.
Primero, un evento de prueba —un PutObject sobre el bucket de manifiestos, exactamente el tipo de llamada que un trail con include_global_service_events capturaría—:
awslocal s3api put-object \
--bucket andes-cargo-shipment-docs \
--key manifests/test-manifest.txt \
--body test-manifest.txt
Qué pasó, literal, al intentarlo:
aws: [ERROR]: Could not connect to the endpoint URL: "http://localhost:4566/andes-cargo-shipment-docs/manifests/test-manifest.txt"
Después, el intento de leer ese evento de vuelta con la operación que el M7.4 ya citó como confirmada en la propia documentación de LocalStack:
awslocal cloudtrail lookup-events --max-results 5
Qué pasó, literal, al intentarlo:
aws: [ERROR]: Could not connect to the endpoint URL: "http://localhost:4566/"
Este es el resultado real, no uno inventado. Ambos comandos fallan por la misma causa raíz, ya nombrada desde el Módulo 1 de esta guía: sin LOCALSTACK_AUTH_TOKEN exportado en este entorno de escritura, el contenedor de LocalStack nunca arranca, así que no hay ningún proceso escuchando en localhost:4566 — cualquier comando awslocal, sin importar cuál, falla con exactamente este error de conexión, no con un error de lógica de negocio ni de permisos.
Lo que este intento honesto sí confirma, y lo que no
Vale la pena separar con precisión qué demuestra este resultado y qué no:
- Confirma que el HCL es correcto.
terraform validateyterraform plan(Pasos 3 y 4) ya lo probaron contra el motor real de Terraform, sin necesitar que LocalStack esté corriendo — elaws_cloudtraily su bucket se aplicarían sin errores de sintaxis o de referencias rotas. - No confirma, en este entorno específico, que un evento real de S3 efectivamente llegue al trail y sea consultable con
lookup-events. Esa confirmación —la que este módulo llama "el intento honesto"— requiere un contenedor de LocalStack corriendo de verdad, algo que este entorno de escritura, sin token, nunca tiene disponible. - Con LocalStack corriendo (con
LOCALSTACK_AUTH_TOKENexportado, en tu propia máquina), el resultado esperado, basándose en la cobertura de API que la propia documentación de LocalStack demuestra paracreate-trail,start-loggingylookup-events, sería una respuesta JSON con el evento del Paso 5 dentro del arregloEvents— con esta forma general, documentada oficialmente por AWS para esta operación:
{
"Events": [
{
"EventId": "<uuid-generado-por-cloudtrail>",
"EventName": "PutObject",
"EventTime": "<timestamp-de-cuando-corriste-el-comando>",
"EventSource": "s3.amazonaws.com",
"Username": "<la-identidad-usada-para-llamar-s3api-put-object>",
"Resources": [
{ "ResourceType": "AWS::S3::Object", "ResourceName": "andes-cargo-shipment-docs/manifests/test-manifest.txt" }
],
"CloudTrailEvent": "<JSON-string-con-el-evento-completo>"
}
]
}
Este bloque está marcado, a propósito, sin ningún EventId ni EventTime inventado — cada uno de esos dos campos depende de cuándo y en qué máquina corras el comando, exactamente el tipo de valor que la tabla de honestidad de esta guía prohíbe presentar como literal fijo. Lo único fijo, si corrieras esto por tu cuenta con LocalStack levantado, sería la forma de la respuesta —los nombres de campo, la estructura del arreglo Events— no sus valores.
Actualizando RISK-MAP.md: la fila que este módulo cierra
- | 7 | TM-03 | Repudiation | No CloudTrail trail, no attributable audit record | CloudTrail (as far as LocalStack Hobby allows) | M7 | Open |
+ | 7 | TM-03 | Repudiation | No CloudTrail trail, no attributable audit record | CloudTrail (as far as LocalStack Hobby allows) | M7 | Resolved (M7.5): `aws_cloudtrail` + dedicated log bucket declared and validated via `terraform validate`/`plan`; `lookup-events` read attempted for real in this environment and documented honestly (endpoint unreachable without a running LocalStack container) |
Fíjate en la redacción de esta fila, comparada con las de módulos anteriores: no dice "Resolved" sin más — dice, con precisión, qué corrió de verdad (el HCL, validado) y qué no pudo confirmarse en este entorno específico (la lectura real del evento), exactamente la misma disciplina de honestidad exacta que gobierna esta guía entera.
Errores comunes
Olvidar depends_on entre el trail y la política del bucket, y encontrarse con un error de permisos solo en apply (de dependencia implícita insuficiente). Qué pasa: alguien declara aws_cloudtrail sin el depends_on del Paso 2, confiando en que s3_bucket_name = aws_s3_bucket.cloudtrail_logs.id ya basta como dependencia. Cómo detectarlo: terraform validate y terraform plan no muestran ningún error —la referencia al bucket es válida—, pero un apply real contra una cuenta AWS podría fallar de forma intermitente, porque Terraform no tiene ninguna garantía de que la política del bucket (un recurso completamente distinto, aws_s3_bucket_policy, que ni siquiera es un argumento de aws_cloudtrail) ya se haya aplicado. Cómo corregirlo: exactamente lo que hace esta lección — un depends_on explícito, para dependencias reales que Terraform no puede inferir de ningún argumento directo entre dos recursos.
Reutilizar el bucket de negocio (andes-cargo-shipment-docs) para los logs de CloudTrail, "para no crear un bucket nuevo" (de ahorro mal aplicado). Qué pasa: alguien, tratando de minimizar el número de recursos nuevos, apunta s3_bucket_name al bucket que ya existe desde terraform-and-iac-guide. Cómo detectarlo: si tu aws_cloudtrail referencia aws_s3_bucket.shipment_docs (o el nombre que use tu proyecto para ese bucket) en vez de un bucket dedicado. Cómo corregirlo: mezclar logs de auditoría con datos de negocio en el mismo bucket rompe la separación de responsabilidades que esta guía practica en cada módulo (secretos separados de config, políticas separadas por propósito) — y, más concretamente, requeriría que la política de ese bucket de negocio incluyera además los permisos de CloudTrail, ensuciando una política que hoy solo resuelve HTTPS obligatorio (Módulo 1, lección 4).
Concluir, del error de conexión del Paso 5, que el HCL de esta lección está mal escrito (de causa equivocada). Qué pasa: alguien, viendo Could not connect to the endpoint URL, revisa el HCL del Paso 1/2 buscando un error de sintaxis. Cómo detectarlo: si tu primera reacción al error del Paso 5 es dudar del terraform validate que ya pasó en el Paso 3. Cómo corregirlo: el error del Paso 5 no tiene ninguna relación con la corrección del HCL — es, literalmente, un error de red (Could not connect), no un error de configuración de Terraform ni de AWS. terraform validate/plan ya confirmaron, de forma completamente independiente, que el HCL es correcto; el error del Paso 5 solo confirma que no hay ningún proceso de LocalStack escuchando en este entorno de escritura específico.
Ejercicios
Ejercicio 1 — Explica, sin mirar el Paso 2, por qué include_global_service_events es obligatorio para que la lección 7 funcione. Sin volver a mirar esta lección, explica en dos o tres frases por qué un trail sin ese flag activo nunca podría alimentar la alarma de IAM que la lección 7 va a construir.
Ver solución
IAM es un servicio global de AWS, no regional — una llamada como iam:AttachRolePolicy no "sucede" en ninguna región específica, a diferencia de una llamada a S3 o DynamoDB. Un trail configurado sin include_global_service_events = true solo registra eventos de servicios regionales, y nunca vería llamadas a servicios globales como IAM, STS u Organizations. Sin este flag, el trail de esta lección nunca capturaría el evento iam:AttachRolePolicy que la lección 7 necesita para disparar su alarma — la cadena completa (trail → EventBridge → alarma) se rompería en el primer eslabón, antes de que el evento siquiera llegara a existir dentro del trail.
Ejercicio 2 — Diagnostica un error distinto al de esta lección. Un compañero, corriendo estos mismos comandos en su propia máquina, con LocalStack corriendo de verdad (con LOCALSTACK_AUTH_TOKEN exportado), obtiene este error en el Paso 5: An error occurred (TrailNotFoundException). ¿Qué le faltó hacer, basándote en el orden de los pasos de esta lección?
Ver solución
Probablemente corrió apply sin que el trail llegara a aplicarse completo, o intentó lookup-events antes de que aws_cloudtrail.andes_cargo terminara de crearse — TrailNotFoundException significa, con precisión, que la API está respondiendo (a diferencia del error Could not connect de esta lección, que es un problema de red, no de lógica), pero no encuentra ningún trail con ese nombre en la cuenta. La causa más probable es simplemente el orden: confirmar con awslocal cloudtrail describe-trails que el trail andes-cargo-trail existe y está activo antes de intentar lookup-events, o revisar que el tflocal apply haya terminado sin errores antes de continuar.
Ejercicio 3 — Explica, a alguien que solo leyó el resumen de esta lección, la diferencia entre "el HCL corrió" y "el evento se pudo leer". Un compañero, leyendo solo el título de esta lección ("CloudTrail, hasta donde llega LocalStack"), asume que "hasta donde llega" significa que nada de esta lección funcionó de verdad. Corrígelo, con evidencia específica de esta lección.
Ver solución
Una corrección completa distingue con precisión dos cosas que esta lección mantiene separadas a propósito: "El HCL —el bucket dedicado, su política, y el aws_cloudtrail mismo— corrió de verdad contra el motor real de Terraform: terraform validate pasó, y el plan mostró exactamente los tres recursos que se crearían, sin ningún error. Lo único que no se pudo confirmar en este entorno de escritura específico es la última milla — que un evento real, generado con una llamada de S3, efectivamente llegue al trail y sea consultable con lookup-events—, porque no hay ningún contenedor de LocalStack corriendo aquí. 'Hasta donde llega LocalStack' describe ese límite específico, no una falla generalizada de toda la lección."
Resumen y siguiente paso
En esta lección declaraste el trail real de Andes Cargo —un bucket dedicado con su política de dos statements, y el aws_cloudtrail con include_global_service_events activo—, validado de verdad con terraform fmt/validate/plan. Intentaste, de verdad y sin red de seguridad, generar un evento de prueba y leerlo de vuelta con lookup-events; ambos comandos fallaron con el mismo error de conexión ya conocido desde el Módulo 1, documentado aquí tal cual salió, junto con la forma exacta que tendría la respuesta si este mismo intento corriera contra un LocalStack activo. Cerraste TM-03, la última fila abierta de RISK-MAP.md antes de este módulo.
Antes de avanzar deberías poder: explicar por qué CloudTrail necesita un bucket dedicado con una política de dos statements, no solo un bucket cualquiera; explicar por qué include_global_service_events es obligatorio para lo que la lección 7 construye; y distinguir, con evidencia específica, qué de esta lección corrió de verdad y qué quedó representativo por la ausencia de un token en este entorno.
La lección 6 cambia de tema por completo: nombra Organizations, Control Tower y las SCPs multi-cuenta — el faltante ALTA de VALIDACION.md sin guía asignada — y explica por qué Andes Cargo, una sola cuenta, todavía no los necesita.
Recursos
- AWS Docs — Creating a trail for your AWS account — referencia oficial del recurso declarado en el Paso 2, incluida la explicación de
include_global_service_events. - AWS CLI —
cloudtrail lookup-events— referencia oficial completa del comando del Paso 5, incluida la forma exacta de la respuesta (Events,EventId,EventTime,Resources,CloudTrailEvent). - Terraform Registry —
aws_cloudtrail— referencia completa del recurso, incluidosis_multi_region_trailyenable_logging. - LocalStack Docs — CloudTrail — ya citada en la lección 4, la fuente de la cobertura de API demostrada para
create-trail/lookup-events. - Este curso, Módulo 1, lección 7 (
THREAT-MODEL.md) — el hallazgoTM-03que esta lección resuelve. - Este curso, Módulo 1, lección 8 (
RISK-MAP.md) — la fila que esta lección actualiza aResolved.