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 cliente
3acc_00028473, Jenaro Portero Llorente Martínez, solicitó el borrado
4de todos sus datos personales el 22 de febrero de 2024.
5
6El plazo legal es de 30 días naturales. Hoy es 6 de marzo.
7Llevamos 12 días consumidos. Me quedan 18 días naturales,
8que son 12 laborables.
9
10Si no tenemos el proceso implementado, ejecutado y AUDITADO
11para el 23 de marzo, tengo la obligación legal de notificar
12incumplimiento a la AEPD. Eso es una sanción de entre 10.000
13y 20 millones de euros, o el 4% de la facturación anual --
14lo que sea mayor. Para NeoBank, son 3.8 millones de euros.
15
16No es negociable. No es aplazable. No es 'ya lo hacemos la
17semana 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. Con 2.1M clientes, estadísticamente recibiremos ~15 solicitudes/mes.

### Inventario de PII -- dónde vive Jenaro

Vuelves a tu escritorio y empiezas el inventario. Es el pasó 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-REQ-2024-0147
2-- Cliente: acc_00028473 (Jenaro Portero Llorente Martínez)
3-- Fecha inventario: 2024-03-06
4-- Realizado por: Data Engineer
5
6-- ═══════════════════════════════════════════════════════════════
7-- TABLA 1: clients (tabla maestra de clientes)
8-- ═══════════════════════════════════════════════════════════════
9-- PII encontrada: nombre, email, teléfono, dirección, fecha_nacimiento, dni_hash
10-- Registros afectados: 1
11-- Acción: ANONIMIZAR (mantener registro con campos hasheados)
12SELECT account_id, nombre, email, telefono, dirección, fecha_nacimiento
13FROM clients WHERE account_id = 'acc_00028473';
14
15-- ═══════════════════════════════════════════════════════════════
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 meses
20-- 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_total
24FROM transactions WHERE account_id = 'acc_00028473';
25-- Resultado: 4122 txns, 2022-06-15 a 2024-02-20, 187.432,56 EUR
26
27-- ═══════════════════════════════════════════════════════════════
28-- TABLA 3: kyc_documents (Know Your Customer)
29-- ═══════════════════════════════════════════════════════════════
30-- PII encontrada: scan_dni (imagen), selfie_url, dirección_verificada
31-- 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_at
34FROM kyc_documents WHERE account_id = 'acc_00028473';
35
36-- ═══════════════════════════════════════════════════════════════
37-- TABLA 4: communications (emails y SMS enviados)
38-- ═══════════════════════════════════════════════════════════════
39-- PII encontrada: email_destino, telefono_destino, contenido del mensaje
40-- Registros afectados: 847 comunicaciones
41-- Acción: NULLIFICAR contenido, ANONIMIZAR destinatario
42SELECT comm_type, count(*) as total
43FROM communications WHERE account_id = 'acc_00028473'
44GROUP BY comm_type;
45-- email: 523, sms: 289, push: 35
46
47-- ═══════════════════════════════════════════════════════════════
48-- TABLA 5: support_tickets (atención al cliente)
49-- ═══════════════════════════════════════════════════════════════
50-- PII encontrada: nombre en texto libre, email, contenido conversaciones
51-- Registros afectados: 12 tickets (4 cerrados, 8 resueltos)
52-- Acción: NULLIFICAR contenido de texto libre, ANONIMIZAR contactó
53SELECT ticket_id, status, created_at, subject
54FROM support_tickets WHERE account_id = 'acc_00028473';
55
56-- ═══════════════════════════════════════════════════════════════
57-- TABLA 6: fraud_alerts (alertas del modelo ML)
58-- ═══════════════════════════════════════════════════════════════
59-- PII encontrada: account_id, transaction_details (puede contener merchant names)
60-- Registros afectados: 3 alertas (todas false positive)
61-- Acción: ANONIMIZAR account_id, mantener para reentrenamiento del modelo
62SELECT alert_id, alert_type, false_positive, timestamp
63FROM fraud_alerts WHERE account_id = 'acc_00028473';
64
65-- ═══════════════════════════════════════════════════════════════
66-- S3 PATHS AFECTADOS
67-- ═══════════════════════════════════════════════════════════════
68-- s3://neobank-lake/raw/clients/2022/06/acc_00028473.json
69-- s3://neobank-lake/silver/transactions/account_id=acc_00028473/ (14 particiones mensuales)
70-- s3://neobank-lake/gold/client_360/acc_00028473.parquet
71
72-- ═══════════════════════════════════════════════════════════════
73-- RESUMEN
74-- ═══════════════════════════════════════════════════════════════
75-- Total tablas afectadas: 6
76-- Total registros: 4.988
77-- Total paths S3: 16 archivos en 3 prefijos
78-- Riesgo: ALTO (contiene KYC con documento de identidad escaneado)
79-- Prioridad: P1 (deadline regulatorio)

Inventario completo de PII -- el mapa de lo que hay que anonimizar

El inventario de PII es el pasó 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: CLI-0000001 a CLI-2100000) 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.

1import hashlib
2import secrets
3import json
4from datetime import datetime, timezone
5from typing import Dict, List, Optional
6from dataclasses import dataclass, field
7
8@dataclass
9class AuditEntry:
10 """Entrada inmutable del log de auditoría."""
11 timestamp: str
12 table: str
13 action: str # "anonymized" | "deleted" | "nullified"
14 field: Optional[str] = None
15 records_affected: int = 1
16
17class GDPRAnonymizer:
18 """
19 Anonimizador de datos personales según RGPD Artículo 17.
20
21 Diseño:
22 - Salt efímero: se genera al inicio y se destruye al final
23 - Hash irreversible: SHA-256 truncado a 16 chars hexadecimales
24 - Audit trail: cada acción queda registrada con timestamp
25 - Compliance report: documento generado para Legal/DPO
26
27 Uso:
28 anon = GDPRAnonymizer("acc_00028473", "GDPR-REQ-2024-0147")
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 """
34
35 def __init__(self, account_id: str, request_id: str):
36 self.account_id = account_id
37 self.request_id = request_id
38 self.salt = secrets.token_hex(32) # 256 bits de entropía
39 self.audit_log: List[AuditEntry] = []
40 self.started_at = datetime.now(timezone.utc)
41 self._salt_destroyed = False
42
43 # Registrar inicio en audit log
44 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 ))
50
51 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]
61
62 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 anonymized
80
81 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 ))
91
92 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 ))
100
101 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 ))
110
111 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 = None
117 self._salt_destroyed = True
118 self.audit_log.append(AuditEntry(
119 timestamp=datetime.now(timezone.utc).isoformat(),
120 table="system",
121 action="salt_destroyed",
122 ))
123
124 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) + 1
129
130 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 destruido
139 "audit_trail": [
140 {"ts": e.timestamp, "table": e.table, "action": e.action, "field": e.field}
141 for e in self.audit_log
142 ],
143 }
144
145# === DEMOSTRACIÓN ===
146anonymizer = GDPRAnonymizer("acc_00028473", "GDPR-REQ-2024-0147")
147
148# Anonimizar registro de cliente
149cliente_original = {
150 "account_id": "acc_00028473",
151 "nombre": "Jenaro Portero Llorente Martínez",
152 "email": "j.portero.llorente@gmail.com",
153 "telefono": "+34612847391",
154 "dirección": "Calle Gran Vía 45, 3ºB, 28013 Madrid",
155 "fecha_nacimiento": "1985-07-14",
156 "fecha_alta": "2022-06-15",
157 "saldo_actual": 12847.33,
158}
159
160cliente_anonimizado = anonymizer.anonymize_record(
161 "clients", cliente_original,
162 pii_fields=["nombre", "email", "telefono", "dirección", "fecha_nacimiento"]
163)
164
165print("=== 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)")
169
170# Registrar otras acciones
171anonymizer.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])
177
178# Destruir salt -- punto de no retorno
179anonymizer.destroy_salt()
180
181# Generar reporte
182report = 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).

  1. 01.Recibir solicitud GDPR de Legal con el account_id y request_id asignado
  2. 02.Verificar identidad del solicitante (lo hace Legal, no datos)
  3. 03.Ejecutar query de inventario para localizar TODOS los registros del cliente
  4. 04.Generar salt efímero y crear instancia de GDPRAnonymizer
  5. 05.Anonimizar tabla clients (nombre, email, teléfono, dirección, DNI)
  6. 06.Anonimizar FK en transactions (mantener amounts y timestamps)
  7. 07.BORRAR registros de kyc_documents (borrado físico, sin valor analítico)
  8. 08.NULLIFICAR contenido de communications y support_tickets
  9. 09.BORRAR paths de S3 específicos del cliente (raw + gold/client_360)
  10. 10.Ejecutar queries de VERIFICACIÓN post-anonimización
  11. 11.Destruir salt (punto de no retorno)
  12. 12.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ÓN
2-- Ejecutar DESPUÉS del proceso y ANTES de destruir el salt
3-- Todas las queries deben devolver 0 registros con PII
4
5-- 1. El account_id original NO debe existir en ninguna tabla
6SELECT 'clients' as tabla, count(*) as matches
7 FROM clients WHERE account_id = 'acc_00028473'
8UNION ALL
9SELECT 'transactions', count(*)
10 FROM transactions WHERE account_id = 'acc_00028473'
11UNION ALL
12SELECT 'communications', count(*)
13 FROM communications WHERE account_id = 'acc_00028473'
14UNION ALL
15SELECT 'support_tickets', count(*)
16 FROM support_tickets WHERE account_id = 'acc_00028473'
17UNION ALL
18SELECT 'fraud_alerts', count(*)
19 FROM fraud_alerts WHERE account_id = 'acc_00028473';
20-- ESPERADO: todos 0
21
22-- 2. Buscar PII textual (nombre, email, teléfono)
23SELECT 'clients' as tabla, count(*) as matches
24 FROM clients WHERE nombre LIKE '%Portero%'
25 OR email LIKE '%j.portero%'
26 OR telefono LIKE '%612847391%'
27UNION ALL
28SELECT 'support_tickets', count(*)
29 FROM support_tickets WHERE contenido LIKE '%Portero%'
30 OR contenido LIKE '%j.portero%';
31-- ESPERADO: todos 0
32
33-- 3. KYC debe estar BORRADO (no anonimizado)
34SELECT count(*) as kyc_restante FROM kyc_documents
35WHERE account_id = 'acc_00028473'
36 OR account_id LIKE 'ANON_%2847391%';
37-- ESPERADO: 0 (borrado físico completo)
38
39-- 4. Verificar que datos ANONIMIZADOS siguen disponibles para analytics
40SELECT count(*) as txns_anonimizadas,
41 sum(amount) as volumen_conservado,
42 min(timestamp) as primera_txn,
43 max(timestamp) as ultima_txn
44FROM transactions WHERE account_id LIKE 'ANON_%';
45-- ESPERADO: 4122 transacciones con importes intactos
46
47-- 5. Generar resumen para el compliance report
48SELECT tabla, registros_originales, accion_realizada, verificado
49FROM (VALUES
50 ('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', 3, 'FK ANONIMIZADA', 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.

## ejercicios

[01]

Inventario de PII automatizado

Implementa una función que dado un account_id, consulte todas las tablas simuladas y devuelva un inventario completo con registros afectados, campos PII y nivel de riesgo.

Cargando editor...
[02]

Implementar GDPRAnonymizer completo

Implementa la clase GDPRAnonymizer con hash SHA-256+salt efímero, audit trail inmutable y generación de compliance report para Sara.

Cargando editor...
[03]

Queries de verificación post-anonimización

Escribe las queries SQL que verifican que no queda PII en ninguna tabla después de la anonimización. Deben ser exhaustivas para pasar una auditoría de la AEPD.

Cargando editor...

Regístrate para guardar tu progreso.

## comentarios

Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.

Inicia sesión para comentar y responder.

cargando comentarios...