Módulo 7: Detective Vs Preventive Guardrails

3. Manos a la obra: una permission boundary para `AppServerRole`

Descripción

La lección 2 dejó el concepto listo: un permission boundary es un techo independiente, evaluado como intersección con la política de permisos de un rol. Esta lección construye ese techo de verdad, en HCL, para AppServerRole — el rol de solo lectura de Andes Cargo, el mismo que la lección 7 del Módulo 2 ya recortó a photos/* únicamente. El recurso se declara, se valida y se planifica con el motor real de Terraform; el bloqueo real de una llamada que el boundary debería rechazar queda representativo, por la razón exacta que ya citó la lección 2.

Conexión con el módulo

Esta lección es la primera pieza construida de este módulo, después de dos lecciones puramente conceptuales. Extiende dos archivos que ya conoces —modules/iam-role/ (Módulo 5 de terraform-and-iac-guide, endurecido en el Módulo 2 de esta guía) y iam.tf (donde vive AppServerRole)— con una pieza genuinamente nueva: permission-boundary.tf.


Paso 1 — Extendiendo modules/iam-role/variables.tf con un input opcional

El módulo iam-role que ya conoces —el molde que la lección 7 del Módulo 5 de terraform-and-iac-guide construyó, y que el Módulo 2 de esta guía reutilizó sin tocar una línea— no tiene, todavía, ningún input para un permission boundary. Agregale uno, con un detalle deliberado: opcional, con default = null, para que las llamadas existentes al módulo (LambdaManifestProcessorRole, que no necesita boundary hoy) sigan funcionando exactamente igual, sin que nadie tenga que tocarlas:

variable "permissions_boundary_arn" {
  description = "ARN of the managed policy used as this role's permissions boundary. Null means no boundary is attached."
  type        = string
  default     = null
}

Esta es la misma disciplina de diseño de módulos que ya viste en terraform-and-iac-guide: un input nuevo, con un valor por defecto seguro, nunca rompe a quien ya está llamando al módulo sin saber que este input existe.


Paso 2 — Extendiendo modules/iam-role/main.tf: una sola línea

resource "aws_iam_role" "this" {
  name                 = var.role_name
  assume_role_policy   = var.trust_policy_json
  permissions_boundary = var.permissions_boundary_arn
  tags                 = var.tags
}

resource "aws_iam_role_policy" "this" {
  name   = "${var.role_name}-policy"
  role   = aws_iam_role.this.id
  policy = var.permissions_policy_json
}

El argumento permissions_boundary del recurso aws_iam_role acepta directamente un ARN — cuando var.permissions_boundary_arn es null (el caso de LambdaManifestProcessorRole), Terraform simplemente no adjunta ningún boundary, el mismo comportamiento que tenía el rol antes de esta lección. El módulo entero sigue siendo un solo molde para los dos roles de Andes Cargo, con o sin boundary según lo que cada llamada le pida — exactamente el mismo principio de generalización que ya probaste en terraform-and-iac-guide, Módulo 5, lección 7.


Paso 3 — permission-boundary.tf: el techo real de AppServerRole

En la raíz de andes-cargo-infra/, un archivo nuevo — la pieza de seguridad que este módulo agrega, nunca un recurso de negocio:

data "aws_iam_policy_document" "app_server_boundary" {
  statement {
    sid     = "BoundaryAllowShipmentDocsReadOnly"
    effect  = "Allow"
    actions = ["s3:GetObject", "s3:ListBucket"]

    resources = [
      "arn:aws:s3:::${var.bucket_name}",
      "arn:aws:s3:::${var.bucket_name}/*",
    ]
  }

  statement {
    sid    = "BoundaryDenyIdentityAndOrgManagement"
    effect = "Deny"

    actions = [
      "iam:*",
      "organizations:*",
      "sts:AssumeRole",
    ]

    resources = ["*"]
  }
}

resource "aws_iam_policy" "app_server_boundary" {
  name        = "AppServerRole-permission-boundary"
  description = "Maximum permissions AppServerRole can ever have, regardless of what its own inline policy grants."
  policy      = data.aws_iam_policy_document.app_server_boundary.json

  tags = local.common_tags
}

Léelo statement por statement, contra el criterio que la lección 2 ya explicó:

  • BoundaryAllowShipmentDocsReadOnly — el techo permite, como máximo, leer el bucket completo de Andes Cargo. Fíjate que es más ancho que la política de permisos actual de AppServerRole (acotada a photos/* desde el Módulo 2, lección 7) — un boundary no tiene por qué ser tan estrecho como la política de permisos; solo tiene que ser lo bastante estrecho como para que nada catastrófico quepa dentro de él. Si mañana AppServerRole necesitara leer también manifests/, ese cambio de política de permisos no chocaría con este boundary — sigue dentro del techo.
  • BoundaryDenyIdentityAndOrgManagement — la parte que hace que este boundary valga la pena: un Deny explícito sobre iam:*, organizations:* y sts:AssumeRole, con Resource: "*". Ninguna política de permisos futura, sin importar qué tan amplia o mal escrita esté, puede lograr que AppServerRole gestione identidades, toque Organizations, o asuma otro rol — la categoría exacta de escalada de privilegios más grave para un rol de solo lectura.

Paso 4 — Adjuntando el boundary a AppServerRole

En iam.tf, la llamada al módulo que ya existe desde el Módulo 2:

module "app_server_role" {
  source = "./modules/iam-role"

  role_name                = "AppServerRole"
  trust_policy_json        = data.aws_iam_policy_document.ec2_trust.json
  permissions_policy_json  = data.aws_iam_policy_document.ec2_permissions.json
  permissions_boundary_arn = aws_iam_policy.app_server_boundary.arn
  tags                     = local.common_tags
}

Una sola línea nueva: permissions_boundary_arn = aws_iam_policy.app_server_boundary.arn. module.lambda_manifest_processor_role, la otra llamada al mismo molde, queda exactamente igual que en el Módulo 2 — sin boundary, porque LambdaManifestProcessorRole no lo necesita hoy (nada en el THREAT-MODEL.md señala ese rol como el de mayor riesgo de escalada; es AppServerRole, con su alcance histórico más amplio, TM-07, el que justifica esta defensa adicional).


Paso 5 — fmt, validate: el motor real, sobre el proyecto completo

terraform fmt -recursive -check -diff
terraform validate

Qué esperar (literal, ejecutado para escribir esta lección):

Success! The configuration is valid.

terraform fmt -check no produjo ninguna diferencia — el HCL de los tres archivos nuevos/editados (variables.tf, main.tf del módulo, permission-boundary.tf) ya sigue el estilo canónico de Terraform sin necesitar ningún ajuste.


Paso 6 — El plan: el techo, y el rol que lo recibe

terraform plan

Qué esperar (literal, ejecutado para escribir esta lección — se muestra la parte de este plan que corresponde al boundary y a AppServerRole; el plan completo de este punto de la guía incluye, además, LambdaManifestProcessorRole sin cambios, y las piezas de las lecciones 5 y 7 de este mismo módulo, que agregan las suyas al mismo plan):

  # aws_iam_policy.app_server_boundary will be created
  + resource "aws_iam_policy" "app_server_boundary" {
      + arn              = (known after apply)
      + attachment_count = (known after apply)
      + description      = "Maximum permissions AppServerRole can ever have, regardless of what its own inline policy grants."
      + id               = (known after apply)
      + name             = "AppServerRole-permission-boundary"
      + name_prefix      = (known after apply)
      + path             = "/"
      + policy           = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = [
                          + "s3:ListBucket",
                          + "s3:GetObject",
                        ]
                      + Effect   = "Allow"
                      + Resource = [
                          + "arn:aws:s3:::andes-cargo-shipment-docs/*",
                          + "arn:aws:s3:::andes-cargo-shipment-docs",
                        ]
                      + Sid      = "BoundaryAllowShipmentDocsReadOnly"
                    },
                  + {
                      + Action   = [
                          + "sts:AssumeRole",
                          + "organizations:*",
                          + "iam:*",
                        ]
                      + Effect   = "Deny"
                      + Resource = "*"
                      + Sid      = "BoundaryDenyIdentityAndOrgManagement"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + policy_id        = (known after apply)
      + tags             = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Project"     = "andes-cargo"
        }
      + tags_all         = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Project"     = "andes-cargo"
        }
    }

  # module.app_server_role.aws_iam_role.this will be created
  + resource "aws_iam_role" "this" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ec2.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "AppServerRole"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = (known after apply)
      + tags                  = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Project"     = "andes-cargo"
        }
      + tags_all              = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Project"     = "andes-cargo"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

El detalle que vale la pena detenerse a leer: permissions_boundary = (known after apply), no el ARN literal. Es exactamente el mismo mecanismo que ya explicó el Módulo 2, lección 5, sobre data.aws_iam_policy_document.trust: permissions_boundary_arn se calcula a partir de aws_iam_policy.app_server_boundary.arn, un valor que todavía no existe porque la política que lo produce se crea en el mismo apply. Terraform no puede mostrar el ARN literal en el plan — solo puede confirmar que la referencia es válida y que se resolverá correctamente en el momento del apply, en el orden correcto: primero la política, después el rol que la referencia.


Paso 7 — Aplicando y verificando (representativo)

Qué esperar (representativo) — este entorno de escritura no tiene un LOCALSTACK_AUTH_TOKEN exportado, así que el contenedor de LocalStack no arranca aquí (Could not connect to the endpoint URL), el mismo límite exacto de cada lección anterior de esta guía:

tflocal apply -auto-approve
awslocal iam get-role --role-name AppServerRole

Qué esperar (representativo — reconstruido campo por campo del plan real de arriba y del formato oficial documentado de iam get-role cuando el rol tiene un boundary adjunto):

{
    "Role": {
        "Path": "/",
        "RoleName": "AppServerRole",
        "RoleId": "AROAQZ3EXAMPLEAPPSERVER",
        "Arn": "arn:aws:iam::000000000000:role/AppServerRole",
        "CreateDate": "2026-08-13T10:22:07+00:00",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": { "Service": "ec2.amazonaws.com" },
                    "Action": "sts:AssumeRole"
                }
            ]
        },
        "MaxSessionDuration": 3600,
        "PermissionsBoundary": {
            "PermissionsBoundaryType": "PermissionsBoundaryPolicy",
            "PermissionsBoundaryArn": "arn:aws:iam::000000000000:policy/AppServerRole-permission-boundary"
        }
    }
}

Ahí está la confirmación: PermissionsBoundary aparece como un bloque propio, separado de AssumeRolePolicyDocument — el rol tiene, ahora, dos mecanismos de control completamente independientes, exactamente como describió la lección 2. Lo que este comando no puede confirmar, en este laboratorio, es que una llamada real que el boundary debería bloquear (por ejemplo, un intento de AppServerRole de ejecutar iam:AttachRolePolicy) efectivamente falle — esa prueba requiere IAM Policy Enforcement, la funcionalidad de pago ya citada en la lección 2, no disponible en el plan gratuito Hobby/Community.


El límite, documentado en el punto exacto donde aparece

Vale la pena decirlo con la misma precisión que el resto de esta guía: terraform validate y terraform plan de esta lección son reales — el HCL es sintácticamente correcto, coherente internamente, y se aplicaría sin errores contra una cuenta AWS real o contra LocalStack con IAM Policy Enforcement activo. Lo que esta lección no puede demostrar, en este laboratorio $0 específico, es el comportamiento que justifica que este control exista: que una llamada real, hecha desde AppServerRole, a una acción denegada por el boundary (por ejemplo, iam:CreateAccessKey), sea efectivamente rechazada con AccessDenied. Esa prueba —el bloqueo mismo, no solo el recurso que lo declara— es exactamente el tipo de verificación que solo una cuenta AWS real, o una instancia de LocalStack con el plan Base o Ultimate, puede ofrecer.


Actualizando RISK-MAP.md

Este control no cierra ninguna fila nueva de RISK-MAP.md por sí solo — TM-07 (elevación de privilegio de AppServerRole) ya quedó Resolved en el Módulo 2, lección 7, con el recorte de la política de permisos a photos/*. Este boundary es una defensa adicional sobre ese mismo riesgo, no la resolución de uno nuevo — vale la pena anotarlo así, sin inflar el mapa con una fila que no corresponde a ningún hallazgo original de THREAT-MODEL.md:

- | 2 | TM-07 | Elevation of privilege | `AppServerRole` broader than its actual usage | Least-privilege role tightening | M2.7 | Resolved (M2.7): both roles' S3 permissions scoped to their exact prefix (`manifests/*`, `photos/*`), verified via `terraform plan` and `awslocal iam get-role-policy` |
+ | 2 | TM-07 | Elevation of privilege | `AppServerRole` broader than its actual usage | Least-privilege role tightening | M2.7 | Resolved (M2.7), hardened (M7.3): scoped S3 policy plus an independent `permissions_boundary` denying `iam:*`/`organizations:*`/`sts:AssumeRole` regardless of future policy changes |

Errores comunes

Declarar el boundary tan estrecho como la política de permisos, en vez de más ancho (de diseño invertido). Qué pasa: alguien escribe el boundary de AppServerRole con exactamente photos/* como único recurso permitido, calcando la política de permisos actual. Cómo detectarlo: si tu boundary y tu política de permisos otorgan, literalmente, el mismo alcance. Cómo corregirlo: no es un error que rompa validate ni plan — pero desperdicia el propósito del boundary, que es proteger contra cambios futuros de la política de permisos, no solo confirmar la de hoy. Si mañana AppServerRole necesitara legítimamente leer manifests/ también, un boundary calcado a photos/* bloquearía ese cambio legítimo tan fácilmente como uno malicioso — el boundary de esta lección permite el bucket completo de lectura precisamente para no quedar tan pegado a la política de permisos actual que deje de ser útil ante un cambio razonable.

Olvidar el default = null en la nueva variable del módulo, y romper la llamada existente para LambdaManifestProcessorRole (de compatibilidad hacia atrás). Qué pasa: alguien agrega permissions_boundary_arn como variable obligatoria, sin default, y terraform validate falla con un error sobre una variable sin valor en la llamada al módulo que no la especifica. Cómo detectarlo: el mensaje de error menciona module.lambda_manifest_processor_role y una variable requerida sin asignar. Cómo corregirlo: exactamente como declaró el Paso 1 — default = null — para que cualquier llamada existente al módulo que no mencione este input siga funcionando sin ningún cambio, la misma disciplina de módulos reutilizables que ya viste en terraform-and-iac-guide.

Confundir el Deny del boundary con una política que "prohíbe usar IAM" en general, en vez de acotarlo a este rol específico (de alcance). Qué pasa: alguien, viendo "Action": "iam:*" con Effect: Deny, se preocupa de que esto afecte a otros roles o a la cuenta completa. Cómo detectarlo: si tu lectura del boundary asume que impacta algo fuera de AppServerRole. Cómo corregirlo: un permissions_boundary es un ARN adjunto a un rol específico —en este caso, únicamente AppServerRole, vía permissions_boundary_arn en su llamada al módulo—; LambdaManifestProcessorRole no tiene ningún boundary adjunto después de esta lección, y sigue operando exactamente igual que antes. El Deny del boundary solo aplica cuando ese rol específico intenta usar esas acciones.


Ejercicios

Ejercicio 1 — Predice el plan para LambdaManifestProcessorRole después de esta lección. Sin volver a mirar el Paso 6, ¿qué cambiaría en el plan de module.lambda_manifest_processor_role.aws_iam_role.this como resultado de esta lección?

Ver solución

Nada. LambdaManifestProcessorRole no recibe ningún boundary en esta lección —solo AppServerRole lo hace, vía su propia llamada al módulo—, y la variable nueva del módulo (permissions_boundary_arn) tiene default = null, así que cualquier llamada que no la mencione explícitamente sigue produciendo exactamente el mismo aws_iam_role de siempre, sin el argumento permissions_boundary afectando su valor real (Terraform lo interpreta como "sin boundary", el mismo comportamiento anterior a esta lección).

Ejercicio 2 — Explica por qué permissions_boundary = (known after apply) en el plan, en vez del ARN literal. Sin volver a mirar el Paso 6, explica en tus propias palabras por qué Terraform no puede mostrar el ARN completo del boundary en el plan, aunque el nombre de la política ("AppServerRole-permission-boundary") sí es literal y conocido de antemano.

Ver solución

Porque el ARN de una política de IAM no es un valor que tú declares — AWS lo genera en el momento de crear el recurso, incorporando el ID de cuenta y un identificador único. aws_iam_policy.app_server_boundary todavía no existe en el momento del plan (se va a crear en el mismo apply), así que su atributo .arn no tiene ningún valor conocido todavía. Es exactamente el mismo mecanismo que el Módulo 2, lección 5, ya explicó para data.aws_iam_policy_document.trust: una referencia a un recurso que se crea en el mismo apply siempre produce un valor (known after apply) en el plan, sin importar que el resto de los campos de ese mismo recurso (como su name, que sí es un literal que tú escribiste) se muestren completos.

Ejercicio 3 — Diseña el boundary que le pondrías a LambdaManifestProcessorRole, si esta guía decidiera agregarle uno. Basándote en el inventario del Módulo 1 (LambdaManifestProcessorRole lee manifests/* y escribe dynamodb:PutItem en Shipments, nada más), escribe, en prosa o en HCL, qué dos statements tendría un boundary razonable para ese rol.

Ver solución

Un boundary razonable tendría la misma forma de dos statements que el de AppServerRole: uno Allow amplio dentro de la categoría que el rol legítimamente necesita —por ejemplo, s3:* y dynamodb:* sobre los recursos de Andes Cargo, más ancho que la política de permisos actual pero acotado a las dos categorías de servicio que ese rol realmente usa— y uno Deny explícito sobre iam:*, organizations:* y sts:AssumeRole, idéntico en espíritu al de AppServerRole — la categoría de escalada de privilegios que ningún rol de procesamiento de datos debería poder alcanzar, sin importar cuánto creciera su política de permisos legítima con el tiempo. El punto del ejercicio no es memorizar HCL nuevo — es confirmar que el criterio de diseño ("techo amplio dentro de la categoría correcta, denegación explícita de identidad/organización") generaliza a cualquier rol de Andes Cargo, no solo a AppServerRole.


Resumen y siguiente paso

En esta lección construiste el primer permission boundary real de Andes Cargo: extendiste modules/iam-role/ con un input opcional y compatible hacia atrás, declaraste permission-boundary.tf con dos statements (un techo amplio de solo-lectura, una denegación explícita de identidad/organización), y adjuntaste ese boundary únicamente a AppServerRole. Corriste fmt/validate/plan de verdad sobre el proyecto completo, confirmaste el mecanismo de (known after apply) sobre una referencia entre dos recursos nuevos en el mismo apply, y documentaste con precisión el límite exacto de lo que este laboratorio puede probar: el recurso es real, el bloqueo que justifica su existencia queda representativo, con la misma fuente citada desde la lección 2.

Antes de avanzar deberías poder: explicar por qué el boundary de esta lección es más ancho que la política de permisos actual de AppServerRole, no más estrecho; escribir de memoria la estructura de dos statements (Allow amplio + Deny explícito de IAM/Organizations); y explicar, sin ayuda, qué exactamente impide que esta lección demuestre el bloqueo real.

La lección 4 cambia de categoría por completo: deja lo preventivo atrás y nombra los tres guardrails detectivos centrales de AWS — CloudTrail, Config y GuardDuty — antes de que la lección 5 construya el primero de los tres hasta donde este laboratorio lo permite.

Recursos

  1. AWS Docs — Permissions boundaries for IAM entities — la fuente oficial ya citada en la lección 2, con el ejemplo completo de evaluación de permisos efectivos.
  2. Terraform Registry — aws_iam_role — referencia completa del argumento permissions_boundary, usado en el Paso 2.
  3. Terraform Registry — aws_iam_policy — referencia completa del recurso declarado en el Paso 3.
  4. AWS CLI — iam get-role — referencia completa del comando de verificación del Paso 7, incluido el campo PermissionsBoundary.
  5. Este curso, Módulo 2, lección 5 — el mecanismo (known after apply) sobre una dependencia entre dos recursos del mismo apply, ya explicado ahí para data.aws_iam_policy_document.trust.