lección 3
Día 2 — GDPR: diseñar proceso de supresión
Sara te explica el artículo 17. Inventarías PII en 6 tablas, diseñas anonimización irreversible con SHA-256 + salt efímero y construyes el GDPRAnonymizer.
⏱ 45 min
### Miércoles, 9:00 AM — La reunión con Sara
Sara Vidal te espera en la sala Ada Lovelace con un café cortado y una carpeta azul con el logo de la AEPD. Es puntual al segundo — llegas a las 9:01 y ya tiene el portátil abierto con un cronograma proyectado en la pantalla. "Gracias por venir puntual. Voy directa al grano porque el reloj corre y cada día que pasa sin resolver esto es un día más cerca de tener que notificar incumplimiento."
1Sara Vidal (sala Ada Lovelace, 9:02 AM):2"Artículo 17 del RGPD. Derecho de supresión. El cliente3acc_00028473, Jenaro Portero Llorente, solicitó el borrado4de todos sus datos personales el 7 de septiembre de 2024.56El plazo legal es de 30 días naturales. Hoy es 18 de septiembre.7Llevamos 11 días consumidos. Me quedan 19 días naturales,8que son 13 laborables.910Si no tenemos el proceso implementado, ejecutado y AUDITADO11para el 7 de octubre, tengo la obligación legal de notificar12incumplimiento a la AEPD.1314El artículo 83.5 fija el tope en 20 millones de euros o el 4%15de la facturación anual global, lo que sea MAYOR. El 4% de16NeoBank son 3,8 millones, así que en nuestro caso el techo17son los 20 millones. Esa regla existe para que a las empresas18pequeñas no les salga barato.1920Nadie espera la multa máxima por una petición. Pero el21expediente sí llega, y con él la inspección de todo lo demás.2223No es negociable. No es aplazable. No es 'ya lo hacemos la24semana que viene'. Es ESTO o es una multa."
Sara explica el deadline regulatorio — sin margen de error
Sara te pasa un documento de 3 páginas con los requisitos legales exactos. Los lees con atención porque cada palabra tiene implicaciones técnicas:
- Anonimización IRREVERSIBLE — no pseudonimización. No debe ser posible re-identificar al cliente bajo ninguna circunstancia, ni con acceso a la base de datos completa.
- Aplica a TODOS los sistemas: base operacional (PostgreSQL), data lake (S3 raw/silver/gold), warehouse (Redshift), backups, logs y cualquier sistema que contenga PII.
- Los datos transaccionales pueden CONSERVARSE si se elimina toda PII — NeoBank necesita el histórico para reporting regulatorio al BdE, pero sin identificar al individuo.
- Debe quedar un registro de auditoría INMUTABLE que demuestre qué se borró, cuándo, por quién y con qué método.
- El proceso debe ser REPETIBLE y AUTOMATIZADO — este no será el último cliente que lo pida. Llevamos 14 peticiones en dos meses y medio, unas 6 al mes, y la cifra sube cada trimestre.
### Inventario de PII — dónde vive Jenaro
Vuelves a tu escritorio y empiezas el inventario. Es el paso más importante y el que más tiempo consume. Consultas el catálogo de datos (un Confluence desactualizado pero mejor que nada), revisas los schemas de Redshift con information_schema, y exploras los paths de S3 con aws s3 ls. Después de 2 horas de investigación, tienes el mapa completo:
1-- INVENTARIO DE PII PARA SOLICITUD gdpr_0012-- Cliente: acc_00028473 (Jenaro Portero Llorente)3-- Fecha inventario: 2024-09-184-- Realizado por: Data Engineer56-- ═══════════════════════════════════════════════════════════════7-- TABLA 1: clientes (tabla maestra de clientes)8-- ═══════════════════════════════════════════════════════════════9-- PII encontrada: nombre, email, teléfono, direccion, fecha_nacimiento, dni_hash10-- Registros afectados: 111-- Acción: ANONIMIZAR (mantener registro con campos hasheados)12SELECT account_id, nombre, email, telefono, direccion, fecha_nacimiento13FROM clientes WHERE account_id = 'acc_00028473';1415-- ═══════════════════════════════════════════════════════════════16-- TABLA 2: transactions (histórico de movimientos)17-- ═══════════════════════════════════════════════════════════════18-- PII encontrada: account_id como FK (permite re-identificación)19-- Registros afectados: 4.122 transacciones en 14 meses20-- Acción: ANONIMIZAR account_id (mantener amounts para reporting BdE)21SELECT count(*) as total_txns,22 min(timestamp) as primera, max(timestamp) as última,23 sum(amount) as volumen_total24FROM transactions WHERE account_id = 'acc_00028473';25-- Resultado: 4122 txns, 2023-01-05 a 2024-02-20, 187.432,56 EUR2627-- ═══════════════════════════════════════════════════════════════28-- TABLA 3: kyc_documents (Know Your Customer)29-- ═══════════════════════════════════════════════════════════════30-- PII encontrada: scan_dni (imagen), selfie_url, direccion_verificada31-- Registros afectados: 3 (alta original + 2 actualizaciones)32-- Acción: BORRAR FÍSICAMENTE (sin valor analítico, alto riesgo)33SELECT document_type, created_at, verified_at34FROM kyc_documents WHERE account_id = 'acc_00028473';3536-- ═══════════════════════════════════════════════════════════════37-- TABLA 4: communications (emails y SMS enviados)38-- ═══════════════════════════════════════════════════════════════39-- PII encontrada: email_destino, telefono_destino, contenido del mensaje40-- Registros afectados: 847 comunicaciones41-- Acción: NULLIFICAR contenido, ANONIMIZAR destinatario42SELECT comm_type, count(*) as total43FROM communications WHERE account_id = 'acc_00028473'44GROUP BY comm_type;45-- email: 523, sms: 289, push: 354647-- ═══════════════════════════════════════════════════════════════48-- TABLA 5: support_tickets (atención al cliente)49-- ═══════════════════════════════════════════════════════════════50-- PII encontrada: nombre en texto libre, email, contenido conversaciones51-- Registros afectados: 12 tickets (4 cerrados, 8 resueltos)52-- Acción: NULLIFICAR contenido de texto libre, ANONIMIZAR contacto53SELECT ticket_id, status, created_at, subject54FROM support_tickets WHERE account_id = 'acc_00028473';5556-- ═══════════════════════════════════════════════════════════════57-- TABLA 6: fraud_alerts (alertas del modelo ML)58-- ═══════════════════════════════════════════════════════════════59-- PII encontrada: account_id, transaction_details (puede contener merchant names)60-- Registros afectados: 0 (esta cuenta no tiene alertas en el fichero)61-- Acción: Ninguna necesaria62SELECT alert_id, alert_type, false_positive, timestamp63FROM fraud_alerts WHERE account_id = 'acc_00028473';64-- Resultado: 0 filas6566-- ═══════════════════════════════════════════════════════════════67-- S3 PATHS AFECTADOS68-- ═══════════════════════════════════════════════════════════════69-- s3://neobank-lake/raw/clients/2023/01/acc_00028473.json70-- s3://neobank-lake/silver/transactions/account_id=acc_00028473/ (14 particiones mensuales)71-- s3://neobank-lake/gold/client_360/acc_00028473.parquet7273-- ═══════════════════════════════════════════════════════════════74-- RESUMEN75-- ═══════════════════════════════════════════════════════════════76-- Total tablas afectadas: 5 (fraud_alerts no tiene registros de este cliente)77-- Total registros: 4.98578-- Total paths S3: 16 archivos en 3 prefijos79-- Riesgo: ALTO (contiene KYC con documento de identidad escaneado)80-- Prioridad: P1 (deadline regulatorio)
Inventario completo de PII — el mapa de lo que hay que anonimizar
El inventario de PII es el paso MÁS IMPORTANTE y el que más tiempo consume. Si te dejas un sistema sin anonimizar, no solo incumples la ley — puedes generar una brecha de datos. En empresas grandes existe "data lineage" automatizado (herramientas como Collibra o Alation). En NeoBank todavía no — por eso tardas 2 horas revisando manualmente. Esta experiencia es exactamente por la que Alberto quiere que propongas un catálogo la semana que viene.
### Estrategia de anonimización — SHA-256 con salt efímero
Con el inventario completo, diseñas la estrategia. La clave es distinguir entre 3 acciones diferentes según el tipo de dato:
- BORRAR físicamente: datos que no tienen valor analítico sin la persona (KYC, selfies, DNI escaneado). Se eliminan de la base y de S3.
- ANONIMIZAR con hash: datos que queremos conservar para analytics pero sin identificar a la persona (transacciones, alertas). El account_id se reemplaza por un hash irreversible.
- NULLIFICAR: campos de texto libre donde la PII está embebida en texto natural (tickets de soporte, contenido de emails). Se reemplazan por NULL.
La decisión técnica crucial: usar SHA-256 con un salt aleatorio que se DESTRUYE después de la ejecución. ¿Por qué? Sin salt, alguien con acceso a la base podría hashear todos los account_ids posibles (son secuenciales: acc_00000001 a acc_02100000) y hacer una tabla rainbow para re-identificar. Con salt efímero destruido, es matemáticamente imposible revertir el hash — ni siquiera con fuerza bruta.
### Hashear el identificador no impide reidentificar a la persona
Hay una pregunta que Sara te va a hacer y conviene tenerla pensada: si borras el nombre pero dejas las 4.122 transacciones, ¿has anonimizado de verdad? La respuesta honesta es: no del todo. Un historial de transacciones es una huella dactilar. Si alguien sabe que Jenaro compró en una gasolinera de Las Palmas el martes, cenó en un restaurante concreto el jueves y pagó el gimnasio el día 1, con tres o cuatro datos así puede señalar su fila entre dos millones. Está estudiado y publicado: con cuatro puntos de tiempo y lugar se identifica a la gran mayoría de las personas en un fichero de pagos con tarjeta, aunque no haya ni nombres ni números de cuenta.
El hash del account_id protege contra el ataque fácil — alguien con acceso a la base que busca «Jenaro» — y eso ya es mucho. Contra el ataque informado, no. Qué se hace en la práctica:
- Agregar antes de conservar. El BdE necesita volúmenes por producto y por mes, no el detalle de cada compra. Si el reporte regulatorio se construye sobre datos agregados, el detalle se borra.
- Recortar los campos que más identifican. El importe exacto y el minuto exacto son los que más distinguen. Redondear el importe y truncar la hora al día reduce la capacidad de señalar a una persona, y casi nunca estropea el reporte.
- Separar quién puede ver qué. El histórico anonimizado vive en un esquema al que solo accede el equipo de reporting, con registro de accesos.
- Escribirlo. Si conservas datos tras una petición de supresión, el motivo legal — aquí, la obligación de reporte al supervisor — va documentado en el informe de cumplimiento. El artículo 17.3 permite conservar por obligación legal, pero hay que decir cuál.
«Anonimizado» no es una propiedad del campo, es una propiedad del conjunto. Puedes tener una tabla sin un solo nombre y seguir teniendo datos personales.
### Los backups y los logs: la parte que nadie quiere mirar
Los backups y los logs son la parte que hunde los procesos reales. Un backup de hace tres meses tiene los datos de Jenaro dentro y no se puede editar: si lo tocas, deja de ser un backup válido.
- Los backups no se editan: se caducan. Se documenta la política de retención (por ejemplo, 90 días) y se registra que a partir de esa fecha los datos habrán desaparecido por rotación. Mientras tanto, quedan bloqueados: nadie restaura un backup sin pasar por Sara.
- Se guarda una lista de supresiones pendientes. Si algún día hay que restaurar un backup anterior a la petición, lo primero que se ejecuta después de restaurar es la anonimización de todos los que la pidieron. Sin esa lista, restaurar es resucitar los datos.
- Los logs se tratan como cualquier otra tabla. Si en un log hay un account_id, es PII. Lo normal es fijar una retención corta (30-90 días) y documentarla, en vez de intentar reescribirlos.
Y una cosa más: las cadenas que busques en la verificación las sacas del registro, copiadas y pegadas. No de tu cabeza ni del ticket. Sara ha visto auditorías firmadas buscando un teléfono que el cliente había cambiado hace dos años. Una verificación con datos equivocados da cero filas y parece que todo está bien — es la forma más fácil de certificar un incumplimiento sin saberlo.
1import hashlib2import secrets3import json4from datetime import datetime, timezone5from typing import Dict, List, Optional6from dataclasses import dataclass, field78@dataclass9class AuditEntry:10 """Entrada inmutable del log de auditoría."""11 timestamp: str12 table: str13 action: str # "anonymized" | "deleted" | "nullified"14 field: Optional[str] = None15 records_affected: int = 11617class GDPRAnonymizer:18 """19 Anonimizador de datos personales según RGPD Artículo 17.2021 Diseño:22 - Salt efímero: se genera al inicio y se destruye al final23 - Hash irreversible: SHA-256 truncado a 16 chars hexadecimales24 - Audit trail: cada acción queda registrada con timestamp25 - Compliance report: documento generado para Legal/DPO2627 Uso:28 anon = GDPRAnonymizer("acc_00028473", "gdpr_001")29 anon.anonymize_record("clients", record, ["nombre", "email"])30 anon.delete_records("kyc_documents", 3)31 anon.destroy_salt()32 report = anon.generate_compliance_report()33 """3435 def __init__(self, account_id: str, request_id: str):36 self.account_id = account_id37 self.request_id = request_id38 self.salt = secrets.token_hex(32) # 256 bits de entropía39 self.audit_log: List[AuditEntry] = []40 self.started_at = datetime.now(timezone.utc)41 self._salt_destroyed = False4243 # Registrar inicio en audit log44 self.audit_log.append(AuditEntry(45 timestamp=self.started_at.isoformat(),46 table="system",47 action="process_started",48 field=f"account_id={account_id}",49 ))5051 def hash_value(self, value: str) -> str:52 """53 Hash irreversible con salt efímero.54 SHA-256(salt + ":" + value) truncado a 16 chars hex.55 """56 if self._salt_destroyed:57 raise RuntimeError("Salt destruido -- no se pueden generar más hashes")58 combined = f"{self.salt}:{value}".encode("utf-8")59 full_hash = hashlib.sha256(combined).hexdigest()60 return full_hash[:16]6162 def anonymize_record(self, table: str, record: Dict,63 pii_fields: List[str]) -> Dict:64 """65 Anonimiza los campos PII de un registro.66 Los campos no-PII permanecen intactos.67 """68 anonymized = record.copy()69 for field_name in pii_fields:70 if field_name in anonymized and anonymized[field_name] is not None:71 original_value = str(anonymized[field_name])72 anonymized[field_name] = f"ANON_{self.hash_value(original_value)}"73 self.audit_log.append(AuditEntry(74 timestamp=datetime.now(timezone.utc).isoformat(),75 table=table,76 action="anonymized",77 field=field_name,78 ))79 return anonymized8081 def nullify_fields(self, table: str, fields: List[str], count: int) -> None:82 """Registra la nullificación de campos de texto libre."""83 for field_name in fields:84 self.audit_log.append(AuditEntry(85 timestamp=datetime.now(timezone.utc).isoformat(),86 table=table,87 action="nullified",88 field=field_name,89 records_affected=count,90 ))9192 def delete_records(self, table: str, count: int) -> None:93 """Registra el borrado físico de registros."""94 self.audit_log.append(AuditEntry(95 timestamp=datetime.now(timezone.utc).isoformat(),96 table=table,97 action="deleted",98 records_affected=count,99 ))100101 def delete_s3_paths(self, paths: List[str]) -> None:102 """Registra la eliminación de archivos en S3."""103 for path in paths:104 self.audit_log.append(AuditEntry(105 timestamp=datetime.now(timezone.utc).isoformat(),106 table="s3",107 action="deleted",108 field=path,109 ))110111 def destroy_salt(self) -> None:112 """113 Destruye el salt de forma irreversible.114 Después de esto, es matemáticamente imposible revertir los hashes.115 """116 self.salt = None117 self._salt_destroyed = True118 self.audit_log.append(AuditEntry(119 timestamp=datetime.now(timezone.utc).isoformat(),120 table="system",121 action="salt_destroyed",122 ))123124 def generate_compliance_report(self) -> Dict:125 """Genera reporte para Sara/Legal con resumen del proceso."""126 actions_by_type = {}127 for entry in self.audit_log:128 actions_by_type[entry.action] = actions_by_type.get(entry.action, 0) + 1129130 return {131 "request_id": self.request_id,132 "account_id": self.account_id,133 "started_at": self.started_at.isoformat(),134 "completed_at": datetime.now(timezone.utc).isoformat(),135 "salt_destroyed": self._salt_destroyed,136 "total_audit_entries": len(self.audit_log),137 "actions_summary": actions_by_type,138 "compliant": self._salt_destroyed, # Solo es compliant si el salt fue destruido139 "audit_trail": [140 {"ts": e.timestamp, "table": e.table, "action": e.action, "field": e.field}141 for e in self.audit_log142 ],143 }144145# === DEMOSTRACIÓN ===146anonymizer = GDPRAnonymizer("acc_00028473", "gdpr_001")147148# Anonimizar registro de cliente149cliente_original = {150 "account_id": "acc_00028473",151 "nombre": "Jenaro Portero Llorente",152 "email": "jenaro.llorente28473@hotmail.com",153 "telefono": "+34617004390",154 "dirección": "Plaza Pablo Sevilla 179, Las Palmas",155 "fecha_nacimiento": "1985-07-14",156 "fecha_alta": "2022-06-15",157 "saldo_actual": 12847.33,158}159160cliente_anonimizado = anonymizer.anonymize_record(161 "clients", cliente_original,162 pii_fields=["nombre", "email", "telefono", "dirección", "fecha_nacimiento"]163)164165print("=== ANTES vs DESPUÉS ===")166print(f"Nombre: {cliente_original['nombre']:40s} -> {cliente_anonimizado['nombre']}")167print(f"Email: {cliente_original['email']:40s} -> {cliente_anonimizado['email']}")168print(f"Saldo: {cliente_original['saldo_actual']:40} -> {cliente_anonimizado['saldo_actual']} (NO es PII)")169170# Registrar otras acciones171anonymizer.delete_records("kyc_documents", 3)172anonymizer.nullify_fields("support_tickets", ["contenido", "nombre_agente"], 12)173anonymizer.delete_s3_paths([174 "s3://neobank-lake/raw/clients/2022/06/acc_00028473.json",175 "s3://neobank-lake/gold/client_360/acc_00028473.parquet",176])177178# Destruir salt -- punto de no retorno179anonymizer.destroy_salt()180181# Generar reporte182report = anonymizer.generate_compliance_report()183print(f"\n=== REPORTE DE COMPLIANCE ===")184print(f"Request: {report['request_id']}")185print(f"Salt destruido: {report['salt_destroyed']}")186print(f"Acciones totales: {report['total_audit_entries']}")187print(f"Compliant: {report['compliant']}")
GDPRAnonymizer completo: hash irreversible + audit trail + compliance report
### El runbook de supresión — proceso repetible
Sara insiste en que el proceso sea un runbook documentado — un procedimiento pasó a pasó que cualquier persona del equipo pueda ejecutar cuando llegue la próxima solicitud (y llegarán: con 2.1M clientes, estadísticamente 15/mes).
- 01.Recibir solicitud GDPR de Legal con el account_id y request_id asignado
- 02.Verificar identidad del solicitante (lo hace Legal, no datos)
- 03.Ejecutar query de inventario para localizar TODOS los registros del cliente
- 04.Generar salt efímero y crear instancia de GDPRAnonymizer
- 05.Anonimizar tabla clientes (nombre, email, teléfono, dirección, DNI)
- 06.Anonimizar FK en transactions (mantener amounts y timestamps)
- 07.BORRAR registros de kyc_documents (borrado físico, sin valor analítico)
- 08.NULLIFICAR contenido de communications y support_tickets
- 09.BORRAR paths de S3 específicos del cliente en las TRES zonas: raw, silver (las 14 particiones mensuales del histórico) y gold/client_360. Olvidar silver es el fallo más común: es donde vive el detalle
- 10.BLOQUEAR los backups que contienen al cliente y documentar la política de retención (90 días). Un backup no se edita: se caduca. Nadie restaura sin pasar por Sara
- 11.AÑADIR el account_id a la lista de supresiones pendientes, para reaplicar la anonimización si algún día hay que restaurar un backup anterior a la petición
- 12.REVISAR los logs: si contienen el account_id, es PII. Documentar la retención corta (30-90 días) en vez de intentar reescribirlos
- 13.Ejecutar queries de VERIFICACIÓN post-anonimización (con las cadenas copiadas del registro, no de memoria)
- 14.Destruir salt (punto de no retorno)
- 15.Generar compliance report y enviarlo a Sara con copia a Legal
### Verificación post-anonimización
Sara es inflexible en un punto: "Si no puedes DEMOSTRAR que no queda PII, no puedo certificar cumplimiento". Las queries de verificación son tan importantes como la anonimización en sí:
1-- VERIFICACIÓN POST-ANONIMIZACIÓN2-- Ejecutar DESPUÉS del proceso y ANTES de destruir el salt3-- Todas las queries deben devolver 0 registros con PII45-- 1. El account_id original NO debe existir en ninguna tabla6SELECT 'clients' as tabla, count(*) as matches7 FROM clients WHERE account_id = 'acc_00028473'8UNION ALL9SELECT 'transactions', count(*)10 FROM transactions WHERE account_id = 'acc_00028473'11UNION ALL12SELECT 'communications', count(*)13 FROM communications WHERE account_id = 'acc_00028473'14UNION ALL15SELECT 'support_tickets', count(*)16 FROM support_tickets WHERE account_id = 'acc_00028473'17UNION ALL18SELECT 'fraud_alerts', count(*)19 FROM fraud_alerts WHERE account_id = 'acc_00028473';20-- ESPERADO: todos 02122-- 2. Buscar PII textual (nombre, email, teléfono)23SELECT 'clients' as tabla, count(*) as matches24 FROM clientes WHERE nombre LIKE '%Portero Llorente%'25 OR email LIKE '%jenaro.llorente28473%'26 OR telefono LIKE '%617004390%'27UNION ALL28SELECT 'support_tickets', count(*)29 FROM support_tickets WHERE contenido LIKE '%Portero%'30 OR contenido LIKE '%jenaro.llorente%';31-- ESPERADO: todos 03233-- 3. KYC debe estar BORRADO (no anonimizado)34SELECT count(*) as kyc_restante FROM kyc_documents35WHERE account_id = 'acc_00028473'36 OR account_id LIKE 'ANON_%2847391%';37-- ESPERADO: 0 (borrado físico completo)3839-- 4. Verificar que datos ANONIMIZADOS siguen disponibles para analytics40SELECT count(*) as txns_anonimizadas,41 sum(amount) as volumen_conservado,42 min(timestamp) as primera_txn,43 max(timestamp) as ultima_txn44FROM transactions WHERE account_id LIKE 'ANON_%';45-- ESPERADO: 4122 transacciones con importes intactos4647-- 5. Generar resumen para el compliance report48SELECT tabla, registros_originales, accion_realizada, verificado49FROM (VALUES50 ('clients', 1, 'ANONIMIZADO', TRUE),51 ('transactions', 4122, 'FK ANONIMIZADA', TRUE),52 ('kyc_documents', 3, 'BORRADO FÍSICO', TRUE),53 ('communications', 847, 'NULLIFICADO', TRUE),54 ('support_tickets', 12, 'NULLIFICADO', TRUE),55 ('fraud_alerts', 0, 'SIN REGISTROS', TRUE)56) AS t(tabla, registros_originales, accion_realizada, verificado);
Queries de verificación — demostrar que no queda PII
NUNCA ejecutes una anonimización GDPR sin verificación posterior. Si dejas un solo registro sin anonimizar y hay una auditoría de la AEPD, la multa puede ser del 4% de la facturación anual. La verificación no es opcional — es parte integral del proceso y debe quedar documentada en el compliance report.
En la práctica, las solicitudes GDPR vienen en oleadas. Cuando un banco grande publica que respeta el derecho al olvido, otros clientes lo piden también por principio. Automatizar AHORA te ahorra cientos de horas en los próximos meses. Sara lo sabe — por eso insiste en el runbook.
Regístrate para guardar tu progreso.