Seguridad · registro de auditoría

Cada consulta deja registro.
Hoy no es verdad.

Esa frase cierra el manual de Amedi. Es una promesa a los pacientes y una obligación legal. Pero la tabla que debía cumplirla está vacía: audit_logs existe en el esquema desde el primer día y nunca la ha escrito nadie. Si mañana alguien lee una historia que no le toca, no queda rastro de que pasó.

Esta propuesta diseña el registro completo — qué se guarda, por qué se permitió, cómo se prueba que nadie lo tocó y qué ve el paciente cuando pregunta.

ISO 27799 §7.10·GDPR Art. 15 y 33· FHIR AuditEvent·retención 6 años

Lo que hay hoy

01

No es que el registro esté incompleto. Es que no existe, y una tabla vacía se parece mucho a una tabla que funciona hasta el día que la necesitas.

audit_logs

Declarada en schema.prisma:1661 con oldValues y newValues como JSON libre. Cero llamadas en todo el código. Ninguna mutación de historia, cita, pago o verificación queda registrada con actor, momento y cambio.

shared_history_access_logs

El único registro de lectura que existe, y cubre un solo flujo: la historia compartida al aceptar una cita. Leer una ficha, un chat, un documento o un pago no deja huella. «¿Quién vio mi historia?» hoy no tiene respuesta.

El piso provisional ya está puesto. pgaudit quedó activo en producción y registra escrituras, DDL y cambios de rol en la capa de Postgres. Pero vive lo que dure la retención de logs del plan — días. Los incidentes se descubren semanas después. Sirve para no estar a ciegas mañana; no sirve para responderle a un regulador dentro de un año.

El sujeto es el paciente

02

La primera decisión es la que condiciona todas las demás: de qué habla un evento. Hay dos escuelas y la diferencia parece cosmética hasta que llega la primera solicitud de acceso.

log de actividad · WorkOS

El paciente es un objeto

actor
quién actúa
targets[]
lista de cosas afectadas — el paciente es una más
action
patient.record.read
context
ip · agente

Responder «¿quién vio la historia de este paciente?» obliga a escarbar dentro de un arreglo JSON en cada fila. Se puede. No se indexa bien.

estándar clínico · FHIR AuditEvent

El paciente es el sujeto

agent[]
quién actúa · si fue el que lo pidió
patient
elemento propio — de quién es el dato
entity[]
qué recursos se tocaron
authorization
por qué se permitió
outcome
si salió bien o falló

La spec lo dice sin rodeos: existe para el «seguimiento determinista de actividades donde el paciente es el sujeto del dato». Un índice, no un escaneo.

«WorkOS es más limpio, el equipo sabe REST, y actor → targets → action cubre todo.»

Cubre todo salvo lo único con plazo legal. El Art. 15 del GDPR convierte «¿quién accedió a mis datos?» en una respuesta obligatoria con fecha límite, no en una consulta ocasional. Un campo propio para el paciente es la diferencia entre contestar en milisegundos y contestar a mano.

De WorkOS sí tomamos lo mejor que tiene: la notación de puntos y, sobre todo, los esquemas registrados por adelantado. Declarar los eventos válidos antes de emitirlos es lo que impide que el registro se pudra en cadenas inventadas por cada quien.

El evento se lee en español

03

Un registro de auditoría que solo entiende un ingeniero no sirve para lo que de verdad se necesita: que un paciente entienda quién vio lo suyo, y que el DPO reconstruya un incidente sin abrir una consola. El evento se guarda estructurado y se cuenta en prosa.

La Dra. Aura Amedi abrió la historia de Carlos Carrasquero
el martes 28 de julio a las 3:40 pm,
porque tenía una cita activa con él.

lo que se guarda

action
patient.record.read
agent
doctor · 8f2c…a91 · requestor: sí
patient
514dce14…82b
entity
medical_record · 3 recursos
authorization
treatment — cita activa 9c1f…
outcome
success
recorded
2026-07-28T19:40:11Z
context
ip · agente · sesión

La frase no se almacena: se compone al leer, a partir de los campos. Cambiar el idioma o el tono no toca el registro. Y el registro nunca guarda el contenido de la historia — solo que fue leída.

El porqué, no solo el qué

04

Este es el campo que falta en casi todos los registros de auditoría, incluido el de WorkOS y los dos tickets que Amedi tiene abiertos. Sin él sabes que la Dra. X leyó la historia de Y — pero no puedes distinguir tratamiento de fisgoneo, que es exactamente el caso que te van a preguntar.

Y no hay que preguntárselo a nadie

La razón ya se calcula: assertCanAccessPatient decide hoy si el acceso se permite, y para decidirlo averigua por qué — médico tratante, cita activa, secretaria de un doctor, historia compartida. Solo hay que anotar esa razón en vez de descartarla.

Y deja ver lo que importa

Con el porqué registrado, la consulta interesante deja de ser «quién leyó qué» y pasa a ser «qué accesos no tienen una razón clínica detrás». Eso es una alerta, no un informe. Y es lo que detecta al insider.

«Estás inventando un campo que ningún ticket pide.»

No lo invento: es authorization en FHIR, presente en dos niveles del estándar, y es el purpose of use que exige la norma clínica. Lo que sí es nuevo es la consecuencia: CB-015 y CB-008 son la misma decisión. No puedes registrar por qué se permitió un acceso hasta haber decidido qué lo permite. CB-015 está en «refining» y en ningún sitio dice que es el prerequisito real de CB-008.

«Si la autorización tiene un fallo, el registro dirá que el acceso fue por tratamiento. El log firma el ataque como legítimo.»

Cierto, y es la objeción más incómoda de toda la propuesta. Un porqué derivado registra lo que el sistema creyó, no lo que pasó. Quien explote un fallo de autorización se lleva un asiento que lo declara legítimo.

Por eso la etiqueta sola es un pasivo. Lo que se guarda no es treatment: es treatment más la evidencia que lo sostuvo — el identificador de la cita, la fila de la membresía, el registro de la historia compartida. Así, cuando la autorización resulte estar mal, la evidencia se puede re-verificar contra el estado real y el acceso se cae solo. Un registro que solo guarde la conclusión no admite ser refutado, y lo que no se puede refutar no es forense.

Dos caminos, dos reglas

05

escritura · quién cambió qué

01Llega la mutación — se crea, actualiza o borra un dato de salud.
02El registro se escribe en la misma transacción. No después. No en paralelo. No en una cola.
03Si la mutación falla, el registro se va con ella. Si el registro falla, la mutación no ocurre.
04Lo que se escribe en la transacción es la bandeja de salida. Firmar, encadenar y anclar ocurre después, fuera del camino del usuario. Atomicidad sin pagarla en latencia.

«Un interceptor que escribe después es más simple y no bloquea la petición.»

Y te deja mutaciones sin registro cuando el proceso muere entre las dos escrituras, y registros sin mutación cuando hay rollback. Un registro de auditoría que a veces miente es peor que no tenerlo, porque lo vas a citar creyéndole. Es el detalle más importante de toda la implementación y no está en el plan de CB-007.

Pero la transacción única tiene un precio real y hay que decirlo: si la escritura del registro falla, el doctor no puede guardar la consulta. Eso es elegir consistencia sobre disponibilidad, y en una clínica no es gratis. Por eso lo que va dentro de la transacción es solo la bandeja de salida — una fila, misma base, sin red ni criptografía. Firmar y anclar viene después. Se conserva la atomicidad y se deja de apostar la consulta del paciente a que el firmante responda.

lectura · quién vio qué

01Se registra el acceso con significado — «abrió la historia de X» — con el conjunto de recursos que tocó.
02No se registra cada SELECT. Abrir una ficha son veinte lecturas: registrarlas todas es ruido y coste, no evidencia.
03Los accesos denegados también se registran. Un intento fallido es la señal más valiosa que existe.

«El registro semántico solo ve lo que alguien se acordó de instrumentar. Un endpoint con un IDOR no aparece.»

Exacto, y Amedi acaba de cerrar un clúster entero de IDOR. La matriz de cobertura te protege de lo que recordaste, no de lo que olvidaste — y un registro incompleto tampoco es forense.

Por eso el registro de la aplicación no puede ser la única fuente. pgaudit ve toda escritura llegue por donde llegue: interfaz, script, editor SQL o migración. La aplicación aporta el significado — quién, por qué, sobre qué paciente — y la base aporta la completitud. Ninguna de las dos basta sola, y el desajuste entre ambas es en sí mismo una señal: una escritura que la base vio y la aplicación no explica es exactamente lo que hay que investigar.

Lo que hace falta para que eso funcione es que el rastro de la base dure, y hoy no dura. Ver la fase 4.

Evidencia, no contenido

06

El registro guarda qué campos cambiaron y un hash de cada valor. Nunca el valor.

Lo que se guarda

changedFields
["diagnostico", "indicaciones"]
before
sha256:9f2a… · sha256:c41b…
after
sha256:77de… · sha256:0a93…

Por qué así

Guardar los valores convierte el registro en una segunda copia de historias clínicas, con seis años de retención y otro control de acceso. Y haría imposible el derecho de supresión: borras la historia y el rastro sigue teniéndola.

«Con hashes no puedes reconstruir qué decía antes. Eso no es forense.»

No hace falta reconstruirlo: hace falta probarlo. El registro demuestra qué campo cambió, cuándo y por quién, y permite verificar un valor que alguien afirme — lo hasheas y comparas. La historia es la fuente de la verdad; el registro prueba que cambió. Y cuando un dato se borra por ley, su hash queda sin significado, que es justo lo correcto.

La cadena y el ancla

07

Cada entrada encadena el hash de la anterior. Alterar una fila rompe todo lo que viene después, y romperlo se nota. Pero eso solo vale si la cabeza de la cadena vive donde el atacante no llega.

01Cada entrada guarda prevHash — la cadena es append-only por construcción.
02Cada entrada se firma con una clave que la aplicación no posee — KMS con permiso de solo firmar. La app puede pedir una firma; no puede fabricarla ni re-firmar el pasado.
03Cada día la cabeza se ancla en almacenamiento con object-lock — DigitalOcean Spaces, que ya se usa.
04Verificar es recalcular la cadena, comprobar cada firma y compararla con el ancla.

«Con el ancla diaria basta: si alguien altera una fila, la cadena no cuadra.»

No basta, y esta es la objeción que hundía la primera versión de esta propuesta. Si la exportación la hace la aplicación, quien controle la aplicación puede alterar filas y recalcular la cadena antes de que corra la exportación. El ancla solo prueba el estado del momento en que se exportó: deja una ventana de un día entera en la que el rastro es reescribible. Y borrarse el rastro propio lleva minutos, no semanas.

Por eso la firma va primero y el ancla después. Con una clave que la app no tiene, recalcular la cadena deja de ser posible: hace falta una firma nueva por cada entrada alterada, y esas firmas no las puede producir quien tomó el servidor. El ancla pasa a ser la segunda línea, no la única.

«El estado del arte es Merkle: Certificate Transparency, Trillian. Una cadena de hashes se quedó en 1998.»

Certificate Transparency existe porque no puedes confiar en el operador del log — miles de autoridades certificadoras, adversarias entre sí, y terceros que verifican entradas sueltas. Esa no es tu amenaza. La tuya es alguien de dentro con acceso a la base.

Y contra esa amenaza, una raíz de Merkle guardada en el mismo Postgres que controla el atacante es teatro. Lo que importa no es la estructura del árbol: es dónde vive la raíz de confianza. Merkle aporta pruebas de inclusión en O(log n), que sirven cuando un tercero verifica entradas una a una. Aquí nadie lo hace todavía — la cadena se diseña para que Merkle se pueda añadir encima sin migrar nada.

quién audita al auditor

Si el rol de la aplicación puede hacer UPDATE o DELETE sobre la tabla, el registro no vale nada contra un administrador. Append-only se impone en Postgres, revocando esos permisos — no por convención ni por revisión de código. Y leer el registro es un rol aparte del de escribirlo.

Que alguien lo mire

08

Todo lo anterior construye evidencia. La evidencia no es una defensa: un registro que nadie consulta es un artefacto de cumplimiento. Sin un bucle que lo lea, te enteras de la brecha porque te la cuenta el paciente — y para entonces ya hay que notificarla.

tres bucles, de más rápido a más fiable

La regla · segundos

Dispara sobre lo que no debería pasar nunca: acceso sin razón clínica detrás, un acceso denegado seguido del mismo acceso concedido, volumen anómalo de fichas abiertas por una misma persona, lectura de un paciente sin cita ni relación. Barato y ruidoso: sirve para lo obvio.

La revisión · semanal

Alguien con nombre revisa dos listas cortas: los accesos por break-glass y los que la regla marcó. Que sea corta es parte del diseño — una lista que nadie termina de leer no se lee. Y queda registro de que se revisó.

el tercer bucle

El paciente es el mejor detector que vas a tener. Ninguna regla sabe que la doctora que abrió su historia el jueves es su cuñada. Él sí. Darle la pantalla de «quién vio lo mío» no es solo cumplir el Art. 15 del GDPR: es poner un revisor motivado por cada historia clínica, que además conoce el contexto que ningún sistema tiene.

Es también la razón de que el evento se lea en español. Un registro que el sujeto no entiende no lo puede auditar nadie más que tú — y tú eres justo la parte interesada.

«Enseñarle al paciente quién vio su historia va a generar sustos y trabajo de soporte.»

Va a generar preguntas, sí. Y cada pregunta que llegue es un acceso que alguien no supo explicar — que es exactamente lo que quieres descubrir, y barato comparado con descubrirlo en una denuncia. Si el volumen asusta, el problema no es la pantalla: es la cantidad de accesos sin razón.

Absolutamente todo cubierto

09

«Todo cubierto» no es una promesa: es una matriz que o está completa o no lo está. Cada modelo con datos de salud, por cada acción, con su prueba.

modeloleercreareditarborrarhoy
MedicalRecordnada
ConsultationRecordsolo compartida
Patientnada
Appointmentnada
ChatMessagenada
DoctorDocumentnada
ConsultationPaymentnada
S3File · adjuntosnada

La matriz es el criterio de terminado, no una aspiración: una prueba por celda, y la suite falla si aparece un modelo de salud sin instrumentar. Lo que no está en la matriz no se registra — y eso también se decide a propósito, no por olvido.

cómo se construye

1

Decidir qué permite el acceso

Sin el modelo de acceso no hay porqué que registrar. Es el prerequisito real, y hoy figura como una tarea suelta en refinamiento.

CB-015 · assertCanAccessPatient

2

El esquema y la escritura

Rehacer la tabla con el modelo FHIR, el diff redactado y la cadena. Instrumentar las mutaciones en la misma transacción. Append-only en Postgres.

CB-007 · sustrato de escritura

3

La lectura y el porqué

Registrar los accesos con significado y la razón que los permitió. Incluidos los denegados.

CB-008 · sustrato de lectura

4

La firma, el ancla y la durabilidad

Firma en KMS por entrada, exportación diaria de la cabeza a object-lock, y el rastro de pgaudit enviado fuera de la plataforma para que dure más que la retención del plan.

IN-007 · log drains · object-lock

5

Que alguien lo mire

Las alertas, la revisión semanal con dueño, y la pantalla donde el paciente pregunta quién vio lo suyo — el revisor mejor informado que vas a tener.

GDPR Art. 15 · retención 6 años

el criterio

Cuando un paciente pregunte quién vio su historia,
la respuesta tiene que caber en una frase
y ser verdad.