lección 4
Día 3 -- Audit trail inmutable de accesos a datos
Nuevo requisito regulatorio: registrar TODOS los accesos a datos de pacientes en una tabla append-only con triggers automáticos en PostgreSQL. SLA: responder a la AEPD en menos de 72 horas sobre cualquier acceso.
⏱ 45 min
### Miércoles, 9:00 AM -- El requisito de Rosa
Dataset de esta lección (en tu carpeta datos/): datos/access_log_sample.json, en formato JSON Lines (un objeto JSON por línea). Cada registro tiene los campos timestamp, user, database, schema, table, operation, query, rows_returned, duration_ms, session_id, application y source_ip. Es la muestra real de accesos que alimenta el audit trail: úsala para diseñar y probar las consultas de auditoría sobre quién accedió a qué tabla, cuándo y con qué query.
El miércoles empieza con un mensaje de Rosa en #data-privacy que hace que todo el equipo deje lo que está haciendo:
1Rosa Martinez 08:47 AM #data-privacy2@equipo-datos @laura.sanchez34Ayer tuve reunion con la abogada externa (Marta Serrano de Garrigues).5Nos ha informado de una nueva directriz de la AEPD para plataformas6de salud digital: a partir de abril, debemos poder demostrar QUIEN7accedio a CUALQUIER dato de paciente, CUANDO y POR QUE, con un SLA8de respuesta inferior a 72 horas.910Esto significa audit trail COMPLETO e INMUTABLE.1112Requisitos:131. Registrar cada SELECT/INSERT/UPDATE/DELETE sobre tablas de pacientes142. Campos mínimos: usuario, timestamp, tabla, operación, query, IP,15 número de registros afectados163. La tabla de audit debe ser APPEND-ONLY (nadie puede borrar ni17 modificar registros de auditoría)184. Busqueda indexada por paciente_id y por usuario195. Retencion mínima: 24 meses2021Prioridad: MAXIMA. Necesitamos esto operativo antes de abril.2223Laura, esto es para @tu y Javier. Que empiecen hoy.
Rosa no pide -- informa de obligaciones legales con deadlines
Laura te llama a una sala. "Esto es gordo pero no complejo técnicamente. PostgreSQL tiene todo lo que necesitamos: triggers, funciones en PL/pgSQL y podemos hacer la tabla append-only revocando DELETE y UPDATE. El reto es que no impacte al rendimiento -- 180.000 consultas al mes generan muchos registros de auditoría. Necesitamos índices pensados y quizá particionado por mes."
Javier pregunta: "Y qué pasa con los SELECTs? Los triggers de PostgreSQL no se disparan con SELECT..." Laura asiente: "Buen punto. Para auditar lecturas tenemos dos opciones: 1) Middleware en la aplicación que loguea cada query antes de ejecutarla, o 2) pgaudit extensión que audita a nivel de motor. Vamos a usar las dos: pgaudit para el registro a bajo nivel, y un middleware Python para el registro semántico con contexto de negocio."
### Arquitectura del audit trail
El audit trail tiene tres componentes: la tabla de almacenamiento (append-only, inmutable), los triggers automáticos (para INSERT/UPDATE/DELETE), y el middleware de aplicación (para SELECTs con contexto de negocio). Los tres alimentan la misma tabla.
1-- Tabla de audit trail: APPEND-ONLY, inmutable2-- Nadie puede hacer DELETE ni UPDATE sobre está tabla34CREATE TABLE audit_accesos_pacientes (5 id BIGSERIAL PRIMARY KEY,6 timestamp_utc TIMESTAMP NOT NULL DEFAULT NOW(),78 -- QUIEN accedio9 usuario_id VARCHAR(50) NOT NULL,10 usuario_nombre VARCHAR(100),11 usuario_rol VARCHAR(30) NOT NULL, -- medico/enfermero/admin/analista/investigador12 ip_origen INET,13 sesion_id VARCHAR(64),1415 -- QUE hizo16 operación VARCHAR(10) NOT NULL, -- SELECT/INSERT/UPDATE/DELETE17 tabla_afectada VARCHAR(50) NOT NULL,18 query_ejecutada TEXT, -- La query completa (censurada si contiene PII)1920 -- SOBRE QUIEN21 paciente_ids TEXT[], -- Array de pacientes afectados22 num_registros INTEGER DEFAULT 0, -- Cuantos registros se leyeron/modificaron2324 -- CONTEXTO25 finalidad VARCHAR(50), -- asistencial/investigacion/operaciones/admin26 justificacion TEXT, -- Motivo libre (obligatorio para investigacion)2728 -- METADATA29 aplicacion VARCHAR(30), -- webapp/api/etl/adhoc30 duracion_ms INTEGER, -- Tiempo de ejecucion de la query3132 -- Inmutabilidad: hash de la fila anterior para detectar manipulacion33 hash_anterior VARCHAR(64)34);3536-- Indices para busqueda rápida (SLA < 72h pero queremos < 5 segundos)37CREATE INDEX idx_audit_paciente ON audit_accesos_pacientes USING GIN (paciente_ids);38CREATE INDEX idx_audit_usuario ON audit_accesos_pacientes (usuario_id, timestamp_utc DESC);39CREATE INDEX idx_audit_timestamp ON audit_accesos_pacientes (timestamp_utc DESC);40CREATE INDEX idx_audit_tabla ON audit_accesos_pacientes (tabla_afectada, operación);4142-- Particionado por mes para rendimiento (24 meses de retención)43-- En producción usariamos particionado nativo de PostgreSQL 1544-- CREATE TABLE audit_accesos_pacientes (...) PARTITION BY RANGE (timestamp_utc);4546-- INMUTABILIDAD: revocar DELETE y UPDATE a TODOS los usuarios47REVOKE DELETE, UPDATE ON audit_accesos_pacientes FROM PUBLIC;48REVOKE DELETE, UPDATE ON audit_accesos_pacientes FROM app_user;49REVOKE DELETE, UPDATE ON audit_accesos_pacientes FROM data_engineer;50-- Solo el superuser de emergencia puede tocar está tabla (y queda logueado en pg_log)
Tabla append-only con índices para busqueda en < 5 segundos
### Triggers automaticos para DML
Los triggers se disparan automáticamente con cada INSERT, UPDATE o DELETE en las tablas de pacientes. No dependen de que el desarrollador "se acuerde" de loguear -- si tocas la tabla, queda registrado. Laura añade: "Esto es crítico. Un trigger a nivel de BD es infalible -- no importa si alguien hace una query desde DBeaver, desde un script Python o desde la app web. El trigger se dispara igual."
1-- Funcion de auditoría generica para triggers2CREATE OR REPLACE FUNCTION fn_audit_pacientes()3RETURNS TRIGGER AS $$4DECLARE5 paciente_id_valor TEXT;6 usuario_actual TEXT;7 ip_actual INET;8BEGIN9 -- Obtener usuario y contexto de sesion10 usuario_actual := COALESCE(11 current_setting('app.current_user_id', true),12 current_user13 );14 ip_actual := COALESCE(15 current_setting('app.client_ip', true)::INET,16 inet_client_addr()17 );1819 -- Extraer paciente_id segun la operación20 IF TG_OP = 'DELETE' THEN21 paciente_id_valor := OLD.paciente_id;22 ELSE23 paciente_id_valor := NEW.paciente_id;24 END IF;2526 -- Si la tabla es 'pacientes', el id ES el paciente_id27 IF TG_TABLE_NAME = 'pacientes' THEN28 IF TG_OP = 'DELETE' THEN29 paciente_id_valor := OLD.id;30 ELSE31 paciente_id_valor := NEW.id;32 END IF;33 END IF;3435 -- Insertar registro de auditoría36 INSERT INTO audit_accesos_pacientes (37 usuario_id, usuario_rol, ip_origen,38 operación, tabla_afectada,39 paciente_ids, num_registros,40 finalidad, aplicacion41 ) VALUES (42 usuario_actual,43 COALESCE(current_setting('app.current_user_role', true), 'sistema'),44 ip_actual,45 TG_OP,46 TG_TABLE_NAME,47 ARRAY[paciente_id_valor],48 1,49 COALESCE(current_setting('app.finalidad', true), 'operacional'),50 COALESCE(current_setting('app.nombre_aplicacion', true), 'trigger_auto')51 );5253 -- Los triggers AFTER no modifican la fila54 IF TG_OP = 'DELETE' THEN55 RETURN OLD;56 END IF;57 RETURN NEW;58END;59$$ LANGUAGE plpgsql SECURITY DEFINER;6061-- Instalar triggers en TODAS las tablas con datos de pacientes62CREATE TRIGGER trg_audit_pacientes63 AFTER INSERT OR UPDATE OR DELETE ON pacientes64 FOR EACH ROW EXECUTE FUNCTION fn_audit_pacientes();6566CREATE TRIGGER trg_audit_historiales67 AFTER INSERT OR UPDATE OR DELETE ON historiales68 FOR EACH ROW EXECUTE FUNCTION fn_audit_pacientes();6970CREATE TRIGGER trg_audit_prescripciones71 AFTER INSERT OR UPDATE OR DELETE ON prescripciones72 FOR EACH ROW EXECUTE FUNCTION fn_audit_pacientes();7374CREATE TRIGGER trg_audit_consultas75 AFTER INSERT OR UPDATE OR DELETE ON consultas76 FOR EACH ROW EXECUTE FUNCTION fn_audit_pacientes();7778CREATE TRIGGER trg_audit_wearables79 AFTER INSERT OR UPDATE OR DELETE ON wearables80 FOR EACH ROW EXECUTE FUNCTION fn_audit_pacientes();
Un trigger por tabla -- cualquier DML queda registrado automáticamente
Consejo de senior: Los triggers AFTER son mejores que BEFORE para auditoría porque no interfieren con la operación original. Si el trigger falla, la transacción se revierte -- pero un trigger de INSERT en una tabla append-only no debería fallar nunca. Usa SECURITY DEFINER para que el trigger se ejecute con permisos del creador (superuser), no del usuario que dispara la operación.
### Middleware de auditoría para SELECTs
Los triggers no capturan SELECT (es una limitación de PostgreSQL). Para auditar lecturas necesitas un middleware en la capa de aplicación. Laura te muestra el patrón que ya usan para otras cosas: "Tenemos un wrapper de conexión a BD que intercepta todas las queries. Añadimos ahí el registro de auditoría para SELECTs que tocan tablas de pacientes."
1# middleware_auditoria.py -- Registra SELECTs sobre tablas de pacientes2# Este middleware se interpone entre la app y PostgreSQL34import re5import time6import logging7from datetime import datetime8from typing import Optional, List9from contextlib import contextmanager1011logger = logging.getLogger('audit_middleware')1213# Tablas que requieren auditoría en SELECT14TABLAS_AUDITADAS = {15 'pacientes', 'historiales', 'prescripciones',16 'consultas', 'wearables', 'consentimientos'17}1819# Patron para detectar tablas en una query SELECT20PATRON_TABLAS = re.compile(21 r'\bFROM\s+([\w.]+)|\bJOIN\s+([\w.]+)',22 re.IGNORECASE23)242526class AuditMiddleware:27 """Intercepta queries y registra accesos a tablas sensibles."""2829 def __init__(self, db_connection, usuario_id: str, usuario_rol: str, ip: str):30 self.db = db_connection31 self.usuario_id = usuario_id32 self.usuario_rol = usuario_rol33 self.ip = ip3435 @contextmanager36 def query_auditada(self, finalidad: str, justificacion: str = ''):37 """Context manager que audita la query ejecutada."""38 # Setear variables de sesion para los triggers DML39 cursor = self.db.cursor()40 cursor.execute(f"SET LOCAL app.current_user_id = '{self.usuario_id}'")41 cursor.execute(f"SET LOCAL app.current_user_role = '{self.usuario_rol}'")42 cursor.execute(f"SET LOCAL app.client_ip = '{self.ip}'")43 cursor.execute(f"SET LOCAL app.finalidad = '{finalidad}'")4445 yield AuditedCursor(46 cursor, self.db, self.usuario_id, self.usuario_rol,47 self.ip, finalidad, justificacion48 )4950 cursor.close()515253class AuditedCursor:54 """Cursor wrapper que registra automaticamente SELECTs auditables."""5556 def __init__(self, cursor, db, usuario_id, usuario_rol, ip, finalidad, justificacion):57 self._cursor = cursor58 self._db = db59 self._usuario_id = usuario_id60 self._usuario_rol = usuario_rol61 self._ip = ip62 self._finalidad = finalidad63 self._justificacion = justificacion6465 def execute(self, query: str, params=None):66 """Ejecuta query y registra si toca tablas auditadas."""67 inicio = time.time()6869 # Ejecutar la query real70 self._cursor.execute(query, params)71 duracion_ms = int((time.time() - inicio) * 1000)7273 # Determinar si es SELECT sobre tabla auditada74 if query.strip().upper().startswith('SELECT'):75 tablas_tocadas = self._extraer_tablas(query)76 tablas_sensibles = tablas_tocadas & TABLAS_AUDITADAS7778 if tablas_sensibles:79 num_registros = self._cursor.rowcount80 self._registrar_acceso(81 query, tablas_sensibles, num_registros, duracion_ms82 )8384 def _extraer_tablas(self, query: str) -> set:85 """Extrae nombres de tablas de una query SQL."""86 matches = PATRON_TABLAS.findall(query)87 tablas = set()88 for match in matches:89 tabla = match[0] or match[1]90 # Quitar schema si existe (public.pacientes -> pacientes)91 tablas.add(tabla.split('.')[-1])92 return tablas9394 def _registrar_acceso(95 self, query: str, tablas: set, num_registros: int, duracion_ms: int96 ):97 """Inserta registro de auditoría para el SELECT."""98 # Censurar la query si contiene datos PII en WHERE99 query_censurada = self._censurar_query(query)100101 for tabla in tablas:102 self._db.execute(103 """INSERT INTO audit_accesos_pacientes (104 usuario_id, usuario_rol, ip_origen,105 operación, tabla_afectada, query_ejecutada,106 num_registros, finalidad, justificacion,107 aplicacion, duracion_ms108 ) VALUES (%s, %s, %s, 'SELECT', %s, %s, %s, %s, %s, 'middleware', %s)""",109 (110 self._usuario_id, self._usuario_rol, self._ip,111 tabla, query_censurada, num_registros,112 self._finalidad, self._justificacion, duracion_ms113 )114 )115116 def _censurar_query(self, query: str) -> str:117 """Reemplaza valores PII en la query por [REDACTED]."""118 # Censurar DNIs (8 digitos + letra)119 query = re.sub(r"'\d{8}[A-Z]'", "'[REDACTED_DNI]'", query)120 # Censurar emails121 query = re.sub(r"'[\w.]+@[\w.]+'", "'[REDACTED_EMAIL]'", query)122 # Censurar nombres entre comillas123 query = re.sub(r"nombre\s*=\s*'[^']+'", "nombre='[REDACTED]'", query)124 return query125126 def fetchall(self):127 return self._cursor.fetchall()128129 def fetchone(self):130 return self._cursor.fetchone()
Middleware que audita SELECTs sobre tablas sensibles automáticamente
NUNCA guardes datos PII en el audit trail. Si un médico hace SELECT * FROM pacientes WHERE dni = 12345678Z, el audit trail debe registrar la query pero con el DNI censurado: WHERE dni = [REDACTED_DNI]. El audit trail existe para demostrar QUIEN hizo QUE y CUANDO -- no para crear otra copia de datos sensibles.
## ejercicios
Crear tabla de audit trail con inmutabilidad
Escribe el DDL completo para crear la tabla de audit trail, sus índices, el trigger generico y la instalación en todas las tablas sensibles. Incluye la revocacion de permisos DELETE/UPDATE y la creación de una politica que impida modificar registros antiguos.
Middleware de auditoría en Python
Implementa el AuditMiddleware completo en Python que: 1) intercepta todas las queries, 2) detecta si tocan tablas sensibles, 3) registra en el audit trail con censura de PII, 4) setea variables de sesion para que los triggers DML también tengan contexto.
Consultas de análisis sobre el audit trail
Rosa necesita poder responder preguntas de la AEPD rápido. Escribe 5 queries que respondan: 1) Todos los accesos a un paciente específico, 2) Usuarios que accedieron a más de 100 pacientes en un día, 3) Accesos fuera de horario laboral, 4) Top 10 usuarios por volumen de accesos, 5) Accesos sin justificacion a tablas críticas.
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...