Lab práctico · Semana 7: Seguridad y privacidad en aplicaciones de IA generativa

Detectar y enmascarar PII y restringir el acceso con IAM y KMS

⏱ 90-120 minDificultad: avanzadaTask statements: 3.2

Qué vas a construir

Un pequeño «repositorio de documentos para IA» con las protecciones de privacidad que pide el task statement 3.2:

  1. Un bucket S3 cifrado con una clave KMS gestionada por el cliente (customer managed key), sin acceso público y que rechaza conexiones sin TLS.
  2. Documentos sintéticos con PII (personally identifiable information, datos personales identificables) en el prefijo crudo/.
  3. Tres formas de encontrar y tratar esa PII, cada una con su papel:
    • Amazon Macie para descubrir PII en S3 (inventario y hallazgos a escala).
    • Amazon Comprehend para detectar PII en un texto concreto y enmascararla en tu propio código.
    • Amazon Bedrock Guardrails (ApplyGuardrail) para enmascarar PII en el flujo de la aplicación de IA generativa.
  4. Una copia anonimizada en anonimizado/, que es lo único que podrá leer la aplicación de IA.
  5. Una regla de ciclo de vida que borra los datos crudos a los 30 días (política de retención).
  6. Un rol IAM «analista» de mínimo privilegio: solo puede invocar Nova Micro a través del perfil de inferencia de la UE, leer anonimizado/ y descifrar con la clave KMS a través de S3. Lo validarás con IAM Access Analyzer y el simulador de políticas.
flowchart TB
    subgraph S3["Bucket S3 (SSE-KMS con clave del cliente)"]
        C["crudo/ (PII, expira a 30 días)"]
        A["anonimizado/"]
    end
    C --> MAC["Macie: descubrimiento y hallazgos"]
    C --> COM["Comprehend: DetectPiiEntities"]
    C --> GR["Guardrails: ApplyGuardrail (OUTPUT)"]
    GR --> A
    A --> ROL["Rol analista (IAM mínimo privilegio)"]
    ROL --> BR["Bedrock: eu.amazon.nova-micro-v1:0"]

Antes de empezar

  • Región eu-central-1 (Fráncfort) y AWS CloudShell con tu usuario administrador del lab. Nada de claves en ficheros: CloudShell usa las credenciales de tu sesión.
  • Todos los datos personales de este lab son ficticios. Nunca uses datos reales en un laboratorio.
  • Costes (estimación a octubre de 2026):
    • Una clave KMS gestionada por el cliente cuesta del orden de 1 USD al mes, prorrateado por hora (consulta la página de precios de KMS), más una cantidad ínfima por petición. Un par de horas de lab son céntimos.
    • Macie: al habilitarlo empiezas una prueba gratuita de 30 días que cubre la evaluación de buckets, pero no los trabajos de descubrimiento (classification jobs). Analizar unos pocos KB cuesta una fracción de céntimo; consulta los precios de Macie.
    • Comprehend y Guardrails: céntimos para unas pocas llamadas.
mkdir -p ~/lab14 && cd ~/lab14
export AWS_REGION=eu-central-1
export AWS_DEFAULT_REGION=eu-central-1
export CUENTA=$(aws sts get-caller-identity --query Account --output text)
export BUCKET="lab14-privacidad-${CUENTA}"
echo "$CUENTA $BUCKET"

Paso 1: clave KMS del cliente

Con una clave gestionada por ti controlas quién puede descifrar mediante la política de la clave, puedes auditar cada uso en CloudTrail y puedes revocar el acceso deshabilitándola. La política incluye dos declaraciones: la cuenta (para que IAM funcione como siempre) y el service-linked role de Macie, que necesita kms:Decrypt para leer objetos cifrados con esta clave. Sin esa declaración, Macie no puede analizar los objetos.

cat > politica-clave.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AdministracionPorLaCuenta",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::${CUENTA}:root"},
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "MacieDescifra",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::${CUENTA}:role/aws-service-role/macie.amazonaws.com/AWSServiceRoleForAmazonMacie"},
      "Action": "kms:Decrypt",
      "Resource": "*"
    }
  ]
}
EOF

El rol de Macie todavía no existe (se crea al habilitar Macie) y KMS rechaza políticas con principales inexistentes. Por eso habilitas Macie antes de crear la clave:

aws macie2 enable-macie --status ENABLED
aws iam get-role --role-name AWSServiceRoleForAmazonMacie --query Role.Arn --output text

export KEY_ID=$(aws kms create-key \
  --description "Clave del lab 14 de privacidad" \
  --policy file://politica-clave.json \
  --query KeyMetadata.KeyId --output text)
aws kms create-alias --alias-name alias/lab14-privacidad --target-key-id "$KEY_ID"
export KEY_ARN=$(aws kms describe-key --key-id "$KEY_ID" --query KeyMetadata.Arn --output text)
echo "$KEY_ARN"

Paso 2: bucket cifrado y blindado

aws s3api create-bucket --bucket "$BUCKET" \
  --create-bucket-configuration LocationConstraint=eu-central-1

aws s3api put-public-access-block --bucket "$BUCKET" \
  --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

aws s3api put-bucket-encryption --bucket "$BUCKET" \
  --server-side-encryption-configuration "{
    \"Rules\": [{
      \"ApplyServerSideEncryptionByDefault\": {\"SSEAlgorithm\": \"aws:kms\", \"KMSMasterKeyID\": \"${KEY_ARN}\"},
      \"BucketKeyEnabled\": true
    }]
  }"

cat > politica-bucket.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "SoloTLS",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:*",
    "Resource": ["arn:aws:s3:::${BUCKET}", "arn:aws:s3:::${BUCKET}/*"],
    "Condition": {"Bool": {"aws:SecureTransport": "false"}}
  }]
}
EOF
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://politica-bucket.json

BucketKeyEnabled reduce las llamadas a KMS (y su coste) generando una clave de datos a nivel de bucket.

Paso 3: documentos sintéticos con PII

mkdir -p docs
cat > docs/reclamacion-01.txt <<'EOF'
Reclamación de la clienta Lucía Martín Pérez, con DNI 12345678Z.
Contacto: lucia.martin@example.com, teléfono 600 123 456.
Solicita la devolución del cargo duplicado en su cuenta IBAN ES91 2100 0418 4502 0005 1332.
EOF
cat > docs/reclamacion-02.txt <<'EOF'
El cliente Javier Gómez Ruiz (DNI 87654321X) llamó el 12 de septiembre.
Pide que le escribamos a javier.gomez@example.org o al 699 987 654.
Su cuenta es ES76 0049 1500 0512 3456 7892 y reclama una comisión de mantenimiento.
EOF
cat > docs/nota-interna.txt <<'EOF'
Nota interna: revisar las reclamaciones pendientes de la oficina de Zaragoza.
Responsable: Ana Torres, extensión interna 4321, correo ana.torres@example.net.
EOF
aws s3 cp docs/ "s3://${BUCKET}/crudo/" --recursive
aws s3api head-object --bucket "$BUCKET" --key crudo/reclamacion-01.txt \
  --query "{cifrado:ServerSideEncryption,clave:SSEKMSKeyId}"

El head-object confirma que el objeto está cifrado con aws:kms y tu clave, aunque no lo pediste en la subida: lo aplica el cifrado por defecto del bucket.

Paso 4: detectar PII con Amazon Comprehend

Comprehend detecta PII en inglés y español. En tiempo real tienes dos operaciones:

  • ContainsPiiEntities: ¿hay PII y de qué tipos? (clasificación rápida del documento).
  • DetectPiiEntities: devuelve cada entidad con tipo, puntuación y offsets (posición de inicio y fin).
TEXTO=$(cat docs/reclamacion-01.txt)
aws comprehend contains-pii-entities --language-code es --text "$TEXTO" \
  --query "Labels[].{tipo:Name,puntuacion:Score}" --output table
aws comprehend detect-pii-entities --language-code es --text "$TEXTO" \
  --query "Entities[].{tipo:Type,inicio:BeginOffset,fin:EndOffset,puntuacion:Score}" --output table

La redacción nativa de Comprehend (devolver el texto ya enmascarado) solo existe en los trabajos asíncronos por lotes sobre S3 (StartPiiEntitiesDetectionJob en modo redacción). Para un flujo síncrono, lo habitual es enmascarar tú mismo usando los offsets:

cat > enmascarar_comprehend.py <<'EOF'
import sys
import boto3

comprehend = boto3.client("comprehend", region_name="eu-central-1")

def enmascarar(texto, umbral=0.8):
    entidades = comprehend.detect_pii_entities(Text=texto, LanguageCode="es")["Entities"]
    # Sustituimos de atrás hacia delante para no desplazar los offsets
    for e in sorted(entidades, key=lambda x: x["BeginOffset"], reverse=True):
        if e["Score"] >= umbral:
            texto = texto[:e["BeginOffset"]] + f"[{e['Type']}]" + texto[e["EndOffset"]:]
    return texto

for ruta in sys.argv[1:]:
    print(f"--- {ruta} ---")
    print(enmascarar(open(ruta, encoding="utf-8").read()))
EOF
python3 enmascarar_comprehend.py docs/*.txt

Fíjate en qué detecta bien y qué no. El DNI español no es un tipo universal de Comprehend, así que puede quedar sin enmascarar o clasificarse de otra forma: por eso en el paso 6 añadirás una expresión regular en el guardrail.

Paso 5: descubrir PII en S3 con Amazon Macie

Comprehend analiza el texto que tú le pasas. Macie, en cambio, recorre tus buckets: inventario, postura de seguridad (buckets públicos, sin cifrar…) y hallazgos de datos sensibles con managed data identifiers (identificadores gestionados por AWS) y custom data identifiers (tus regex). Es la herramienta para responder «¿dónde tengo datos personales antes de indexarlos en una knowledge base?».

Crea un identificador personalizado para el DNI y un trabajo de descubrimiento puntual:

export CDI=$(aws macie2 create-custom-data-identifier \
  --name dni-espanol-lab14 \
  --description "DNI español: 8 dígitos y letra" \
  --regex "[0-9]{8}[A-Z]" \
  --keywords DNI \
  --maximum-match-distance 50 \
  --query customDataIdentifierId --output text)
echo "$CDI"

export JOB=$(aws macie2 create-classification-job \
  --job-type ONE_TIME \
  --name lab14-descubrimiento \
  --s3-job-definition "{\"bucketDefinitions\":[{\"accountId\":\"${CUENTA}\",\"buckets\":[\"${BUCKET}\"]}]}" \
  --custom-data-identifier-ids "$CDI" \
  --managed-data-identifier-selector RECOMMENDED \
  --query jobId --output text)
echo "$JOB"

Cuando vuelvas:

aws macie2 describe-classification-job --job-id "$JOB" --query jobStatus --output text

FINDINGS=$(aws macie2 list-findings \
  --finding-criteria "{\"criterion\":{\"classificationDetails.jobId\":{\"eq\":[\"${JOB}\"]}}}" \
  --query findingIds --output text)
aws macie2 get-findings --finding-ids $FINDINGS \
  --query "findings[].{objeto:resourcesAffected.s3Object.key,tipo:type,severidad:severity.description}" \
  --output table

Deberías ver hallazgos de tipo SensitiveData:S3Object/Personal (y quizá /Financial o /CustomIdentifier) para los documentos de crudo/. Cada hallazgo indica qué identificadores coincidieron y cuántas veces. Macie publica los hallazgos en Amazon EventBridge, así que en producción podrías disparar una Lambda que mueva el objeto a cuarentena o avise al responsable.

Paso 6: enmascarar con Bedrock Guardrails

En la aplicación de IA generativa lo natural es usar el filtro de información sensible de Guardrails, que funciona igual delante de cualquier modelo gracias a ApplyGuardrail. Este guardrail solo tiene política de PII, así que no necesita nivel Standard ni cross-Region inference.

cat > guardrail_pii.py <<'EOF'
import time
import boto3

bedrock = boto3.client("bedrock", region_name="eu-central-1")
r = bedrock.create_guardrail(
    name="lab14-pii",
    blockedInputMessaging="Contenido bloqueado por contener datos sensibles.",
    blockedOutputsMessaging="Contenido bloqueado por contener datos sensibles.",
    sensitiveInformationPolicyConfig={
        "piiEntitiesConfig": [
            {"type": t, "action": "ANONYMIZE"}
            for t in ["NAME", "EMAIL", "PHONE", "INTERNATIONAL_BANK_ACCOUNT_NUMBER"]
        ],
        "regexesConfig": [
            {"name": "DNI", "pattern": "[0-9]{8}[A-Z]", "action": "ANONYMIZE"}
        ],
    },
)
gid = r["guardrailId"]
while bedrock.get_guardrail(guardrailIdentifier=gid)["status"] != "READY":
    time.sleep(3)
open("guardrail_id.txt", "w").write(gid)
print("Guardrail listo:", gid)
EOF
python3 guardrail_pii.py
export GID=$(cat guardrail_id.txt)

Ahora anonimiza cada documento de crudo/ y guarda el resultado en anonimizado/:

cat > anonimizar.py <<'EOF'
import os
import boto3

REGION = "eu-central-1"
s3 = boto3.client("s3", region_name=REGION)
rt = boto3.client("bedrock-runtime", region_name=REGION)
BUCKET = os.environ["BUCKET"]
GID = open("guardrail_id.txt").read().strip()

for obj in s3.list_objects_v2(Bucket=BUCKET, Prefix="crudo/")["Contents"]:
    texto = s3.get_object(Bucket=BUCKET, Key=obj["Key"])["Body"].read().decode("utf-8")
    r = rt.apply_guardrail(
        guardrailIdentifier=GID, guardrailVersion="DRAFT",
        source="OUTPUT", content=[{"text": {"text": texto}}],
    )
    limpio = r["outputs"][0]["text"] if r["action"] == "GUARDRAIL_INTERVENED" else texto
    destino = obj["Key"].replace("crudo/", "anonimizado/", 1)
    s3.put_object(Bucket=BUCKET, Key=destino, Body=limpio.encode("utf-8"))
    print(f"{obj['Key']} -> {destino}\n{limpio}\n")
EOF
python3 anonimizar.py

Verás marcadores como {NAME}, {EMAIL}, {PHONE}, {INTERNATIONAL_BANK_ACCOUNT_NUMBER} y el de la regex DNI. Recuerda: el enmascarado se aplica al texto que devuelves, pero la traza (assessments) contiene los valores originales, y los model invocation logs guardan la entrada sin modificar. Si los activas, protégelos (lab 15).

Paso 7: retención con S3 Lifecycle

Los datos crudos solo se necesitan un tiempo limitado (minimización y limitación del plazo de conservación, principios del RGPD). Una regla de ciclo de vida los borra automáticamente:

aws s3api put-bucket-lifecycle-configuration --bucket "$BUCKET" \
  --lifecycle-configuration '{
    "Rules": [{
      "ID": "expirar-crudo-30-dias",
      "Filter": {"Prefix": "crudo/"},
      "Status": "Enabled",
      "Expiration": {"Days": 30}
    }]
  }'
aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET"

Paso 8: rol «analista» con mínimo privilegio

El rol que usará la aplicación de IA solo debe poder:

  • Invocar Nova Micro a través del perfil de inferencia de la UE. Con perfiles de inferencia necesitas permiso sobre el perfil y sobre el modelo fundacional en las regiones de destino; la condición bedrock:InferenceProfileArn impide invocar el modelo directamente o mediante otro perfil (por ejemplo, uno global que podría enrutar fuera de la UE). La API Converse usa el permiso bedrock:InvokeModel.
  • Leer solo anonimizado/.
  • Descifrar con la clave KMS solo a través de S3 (kms:ViaService).
cat > confianza.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"AWS": "arn:aws:iam::${CUENTA}:root"},
    "Action": "sts:AssumeRole"
  }]
}
EOF

PERFIL_ARN="arn:aws:bedrock:eu-central-1:${CUENTA}:inference-profile/eu.amazon.nova-micro-v1:0"

cat > politica-analista.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PerfilDeInferenciaUE",
      "Effect": "Allow",
      "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
      "Resource": "${PERFIL_ARN}"
    },
    {
      "Sid": "ModeloSoloViaPerfilUE",
      "Effect": "Allow",
      "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
      "Resource": "arn:aws:bedrock:*::foundation-model/amazon.nova-micro-v1:0",
      "Condition": {"StringEquals": {"bedrock:InferenceProfileArn": "${PERFIL_ARN}"}}
    },
    {
      "Sid": "LeerSoloAnonimizado",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::${BUCKET}/anonimizado/*"
    },
    {
      "Sid": "DescifrarSoloViaS3",
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "${KEY_ARN}",
      "Condition": {"StringEquals": {"kms:ViaService": "s3.eu-central-1.amazonaws.com"}}
    }
  ]
}
EOF

Antes de desplegarla, valídala con IAM Access Analyzer, que revisa la gramática y buenas prácticas de la política (errores, avisos de seguridad y sugerencias):

aws accessanalyzer validate-policy \
  --policy-type IDENTITY_POLICY \
  --policy-document file://politica-analista.json \
  --query "findings[].{tipo:findingType,detalle:issueCode}" --output table

Una lista vacía significa que no hay problemas. Ahora crea el rol y simula accesos:

aws iam create-role --role-name lab14-analista \
  --assume-role-policy-document file://confianza.json
aws iam put-role-policy --role-name lab14-analista \
  --policy-name minimo-privilegio --policy-document file://politica-analista.json
export ROL_ARN=$(aws iam get-role --role-name lab14-analista --query Role.Arn --output text)

aws iam simulate-principal-policy --policy-source-arn "$ROL_ARN" \
  --action-names s3:GetObject \
  --resource-arns "arn:aws:s3:::${BUCKET}/crudo/reclamacion-01.txt" "arn:aws:s3:::${BUCKET}/anonimizado/reclamacion-01.txt" \
  --query "EvaluationResults[].{recurso:EvalResourceName,decision:EvalDecision}" --output table

aws iam simulate-principal-policy --policy-source-arn "$ROL_ARN" \
  --action-names bedrock:InvokeModel \
  --resource-arns "$PERFIL_ARN" "arn:aws:bedrock:eu-central-1::foundation-model/amazon.nova-pro-v1:0" \
  --query "EvaluationResults[].{recurso:EvalResourceName,decision:EvalDecision}" --output table

Resultado esperado: crudo/ con implicitDeny y anonimizado/ con allowed; el perfil de Nova Micro allowed y Nova Pro implicitDeny.

Comprueba que funciona

  • head-object muestra aws:kms y tu clave.
  • Comprehend devuelve entidades NAME, EMAIL, PHONE e INTERNATIONAL_BANK_ACCOUNT_NUMBER con sus offsets.
  • El trabajo de Macie termina en COMPLETE y genera hallazgos SensitiveData para los objetos de crudo/.
  • Los objetos de anonimizado/ no contienen nombres, emails, teléfonos, IBAN ni DNI.
  • La regla de ciclo de vida aparece en get-bucket-lifecycle-configuration.
  • Access Analyzer no devuelve errores y el simulador confirma el mínimo privilegio.

Limpieza

Hazla en este orden (hay dependencias: la clave no debe borrarse mientras haya objetos que la usen).

cd ~/lab14

# 1. Objetos y bucket
aws s3 rm "s3://${BUCKET}" --recursive
aws s3api delete-bucket --bucket "$BUCKET"

# 2. Trabajo de Macie: los trabajos no se borran; si siguiera en marcha, cancélalo
aws macie2 describe-classification-job --job-id "$JOB" --query jobStatus --output text
# Solo si no está COMPLETE:
# aws macie2 update-classification-job --job-id "$JOB" --job-status CANCELLED

# 3. Identificador personalizado y Macie
aws macie2 delete-custom-data-identifier --id "$CDI"
aws macie2 disable-macie

# 4. Guardrail
aws bedrock delete-guardrail --guardrail-identifier "$GID"

# 5. Rol y política
aws iam delete-role-policy --role-name lab14-analista --policy-name minimo-privilegio
aws iam delete-role --role-name lab14-analista

# 6. Clave KMS: alias y borrado programado (mínimo 7 días)
aws kms delete-alias --alias-name alias/lab14-privacidad
aws kms schedule-key-deletion --key-id "$KEY_ID" --pending-window-in-days 7

cd ~ && rm -rf ~/lab14

disable-macie elimina también los hallazgos y la configuración de Macie en la región. Si ya usabas Macie en tu cuenta para otra cosa, no lo deshabilites: borra solo el identificador personalizado.

Preguntas para pensar como arquitecto

  1. Un banco va a indexar 40 TB de documentos de S3 en una knowledge base y debe garantizar que no entran datos personales sin tratar. ¿Qué combinación propones?
Respuesta

Macie (descubrimiento automatizado o trabajos programados) para localizar qué buckets y objetos contienen PII, con hallazgos en EventBridge que disparen el flujo de tratamiento; redacción por lotes con Comprehend (trabajos asíncronos) o ApplyGuardrail en un pipeline con Step Functions para generar copias anonimizadas; y la knowledge base apuntando solo al prefijo anonimizado. En la consulta, un guardrail con filtro de información sensible como última barrera.

  1. ¿Por qué la condición kms:ViaService en la política del analista añade seguridad aunque ya tenga s3:GetObject solo en anonimizado/?
Respuesta

Porque impide usar la clave directamente (por ejemplo, llamar a kms:Decrypt con un texto cifrado obtenido por otra vía). El rol solo puede descifrar cuando es S3 quien pide la operación en su nombre, y S3 solo lo hará para objetos que el rol puede leer.

  1. El responsable de protección de datos exige que las peticiones a Bedrock y la evaluación del guardrail no salgan de la UE. ¿Qué configuras?
Respuesta

Perfiles de inferencia geográficos eu. (nunca global.), con IAM que solo permita invocar el modelo mediante ese perfil (bedrock:InferenceProfileArn), SCP que limiten aws:RequestedRegion a regiones de la UE, y guardrails en región de la UE (con el perfil eu.guardrail.v1:0 si usas el nivel Standard). Bedrock no usa tus prompts ni respuestas para mejorar los modelos base ni los comparte con los proveedores de modelos.

  1. ¿Qué ventaja tiene cifrar con una clave del cliente frente a la clave gestionada por AWS si el cifrado en reposo ya es obligatorio?
Respuesta

Control y auditoría: decides en la política de la clave quién y qué servicios pueden descifrar, cada uso queda en CloudTrail, puedes deshabilitarla para cortar el acceso de inmediato (crypto-shredding al programar su borrado) y separar funciones entre quien administra la clave y quien usa los datos.


Volver al módulo