🏥
MedConnect
SaaS · 47 clínicas · 12.000 pacientes
RGPD · LOPD-GDD · HIPAA ref.

Auditoría PHI / PII Compliance

📅 18 jun 2026 📋 Versión 1.0 — Pre-release audit 🔍 Evaluado por CULTIVA IA · Legal 🏢 Stack: Next.js 14 + Supabase
Resumen ejecutivo
⚠️
La auditoría detectó 5 vectores de fuga activos que exponen PHI de pacientes a terceros no autorizados. Los riesgos más graves son: la service_role key de Supabase en client-side (acceso total sin RLS) y la ausencia de auditoría (incumplimiento RGPD art. 30 — registros de tratamiento). Se entregan correcciones listas para copiar-pegar y un checklist de despliegue antes del próximo release.
5
Vectores de fuga
2
Críticos (RGPD art. 83)
2
Altos
1
Medios
0
Tablas con RLS activo
Capa 1 — Clasificación de datos sensibles
Tabla Columna Tipo Clasificación Comentario SQL a añadir
patients name text PHI COMMENT ON COLUMN patients.name IS 'PHI: patient_name';
patients dob date PHI COMMENT ON COLUMN patients.dob IS 'PHI: date_of_birth';
patients nif varchar(9) PHI COMMENT ON COLUMN patients.nif IS 'PHI: national_id_ES';
patients email text PHI COMMENT ON COLUMN patients.email IS 'PHI: contact_email';
patients phone text PHI COMMENT ON COLUMN patients.phone IS 'PHI: contact_phone';
appointments diagnosis_notes text PHI COMMENT ON COLUMN appointments.diagnosis_notes IS 'PHI: clinical_notes';
lab_results result_value jsonb PHI COMMENT ON COLUMN lab_results.result_value IS 'PHI: lab_data';
doctor_payouts amount_eur numeric PII COMMENT ON COLUMN doctor_payouts.amount_eur IS 'PII: financial';
doctor_payouts iban varchar(34) PII COMMENT ON COLUMN doctor_payouts.iban IS 'PII: bank_account';
staff dni varchar(9) PII COMMENT ON COLUMN staff.dni IS 'PII: staff_national_id';
clinics name text PUBLIC — no acción requerida
Capa 2 — Row-Level Security (aislamiento multitenant)
PostgreSQL / Supabase
-- ① Habilitar RLS en todas las tablas PHI/PII ALTER TABLE patients ENABLE ROW LEVEL SECURITY; ALTER TABLE appointments ENABLE ROW LEVEL SECURITY; ALTER TABLE lab_results ENABLE ROW LEVEL SECURITY; ALTER TABLE doctor_payouts ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; -- ② Política: personal clínico solo ve pacientes de su clínica CREATE POLICY "staff_read_own_clinic" ON patients FOR SELECT TO authenticated USING (clinic_id IN ( SELECT clinic_id FROM staff_assignments WHERE user_id = auth.uid() AND role IN ('doctor', 'nurse', 'lab_tech', 'receptionist') )); -- ③ Política: médico solo puede insertar pacientes en su clínica CREATE POLICY "staff_insert_own_clinic" ON patients FOR INSERT TO authenticated WITH CHECK (clinic_id IN ( SELECT clinic_id FROM staff_assignments WHERE user_id = auth.uid() AND role IN ('doctor', 'admin') )); -- ④ Test de aislamiento (esperado: 0 filas para clinic_id diferente) -- SET LOCAL role = 'authenticated'; SET LOCAL request.jwt.claim.sub = '<doctor-clinic-A-uuid>'; -- SELECT count(*) FROM patients WHERE clinic_id = '<clinic-B-uuid>'; -- debe retornar 0
Capa 3 — Tabla de auditoría (insert-only, a prueba de manipulación)
SQL
CREATE TABLE audit_log ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, created_at timestamptz DEFAULT now() NOT NULL, user_id uuid NOT NULL REFERENCES auth.users(id), patient_id uuid REFERENCES patients(id), action text NOT NULL CHECK (action IN ('create','read','update', 'delete','print','export')), resource_type text NOT NULL, resource_id uuid NOT NULL, changes jsonb, ip_address inet NOT NULL, session_id text NOT NULL ); -- Políticas: solo INSERT — no UPDATE ni DELETE CREATE POLICY "audit_insert_only" ON audit_log FOR INSERT TO authenticated WITH CHECK (user_id = auth.uid()); CREATE POLICY "audit_no_modify" ON audit_log FOR UPDATE USING (false); CREATE POLICY "audit_no_delete" ON audit_log FOR DELETE USING (false);
TypeScript helper
// lib/audit.ts — llamar en cada acción sobre PHI interface AuditEntry { user_id: string; patient_id?: string; action: 'create'|'read'|'update'|'delete'|'print'|'export'; resource_type: string; resource_id: string; changes?: { before: object; after: object }; ip_address: string; session_id: string; } export async function logAudit( supabase: SupabaseClient, entry: AuditEntry ) { const { error } = await supabase .from('audit_log') .insert(entry); if (error) { // Log server-side con ID opaco, nunca datos del paciente logger.error('Audit insert failed', { resourceId: entry.resource_id }); } } // Uso en API route: await logAudit(supabase, { user_id: session.user.id, patient_id: patient.id, // UUID opaco action: 'read', resource_type: 'patient_history', resource_id: patient.id, ip_address: req.ip, session_id: session.id, });
Capa 4 — Corrección de vectores de fuga detectados
1
PHI en mensajes de error — Sentry expone patient.name y patient.nif  CRÍTICO
❌ throw new Error(`Paciente ${patient.name} (NIF: ${patient.nif}) no encontrado en ${clinic.name}`);
✅ logger.error('Patient lookup failed', { recordId: patient.id, clinicId: clinic.id }); throw new Error('Registro no encontrado');
Aplica a: todos los catch() de rutas API. Sanitizar stack traces antes de enviar a Sentry con beforeSend hook.
2
service_role key en client-side (Next.js) — bypass total de RLS  CRÍTICO
❌ // .env.local (commiteado) NEXT_PUBLIC_SUPABASE_SERVICE_KEY=eyJhbGc... ← acceso como superusuario desde el browser
✅ NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc... ← solo anon key en el cliente SUPABASE_SERVICE_KEY=eyJhbGc... ← service key SOLO en variables servidor (sin NEXT_PUBLIC_)
Acción inmediata: rotar la service_role key en Supabase Dashboard → Settings → API → Rotate secret. Añadir .env.local a .gitignore y purgar del historial git.
3
Sin expiración de sesión — sesiones de médico permanentes  ALTO
❌ // supabase/config.toml — sin timeout [auth] # jwt_expiry no configurado → default 3600s pero refresh_token nunca expira
✅ [auth] jwt_expiry = 3600 # token de acceso: 1 hora refresh_token_reuse_interval = 0 # En Supabase Dashboard → Auth → Sessions → Inactivity timeout: 8h
Complementar con middleware Next.js que redirige a /login si el token expira y no se renueva tras inactividad.
4
console.log() con objeto paciente completo  ALTO
❌ console.log('Procesando paciente:', patient); // → logs: { id, name, nif, dob, email, phone, diagnosis_notes, ... }
✅ console.log('Procesando registro:', patient.id); // → logs: { id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479' } ← UUID opaco
Implementar regla ESLint personalizada no-patient-console para detectar console.log con variables patient/paciente en CI.
5
PHI en parámetros de URL  MEDIO
❌ /api/patients?name=García+López&nif=12345678A // → queda en logs de Vercel, historial del navegador y Referer headers
✅ /api/patients/f47ac10b-58cc-4372-a567-0e02b2c3d479/history // → solo UUID opaco en la URL, sin datos identificativos
Revisar todas las rutas dinámicas. Búsquedas por nombre/NIF: usar POST con body cifrado en tránsito (HTTPS obligatorio), nunca query params.
Checklist de despliegue — Próximo release
Código y logging
✗
Sin PHI en mensajes de error o stack traces
✗
Sin PHI en console.log/console.error
✗
Sin PHI en parámetros de URL o query strings
✗
Sin PHI en localStorage o sessionStorage
✗
Sentry beforeSend filtra datos de pacientes
Base de datos
✗
RLS habilitado en todas las tablas PHI/PII
✗
Políticas de aislamiento por clinic_id creadas
✗
Tabla audit_log creada con políticas insert-only
✗
Columnas PHI/PII etiquetadas con COMMENT
✗
Test de aislamiento cross-clinic ejecutado (0 rows)
Autenticación y claves
✗
service_role key rotada y fuera del cliente
✗
Solo anon key expuesta en variables NEXT_PUBLIC_
✗
Expiración de sesión configurada (jwt_expiry: 3600)
✗
Timeout de inactividad configurado (8h clínico)
✗
Autenticación requerida en todos los endpoints PHI
Auditoría y conformidad
✗
logAudit() llamado en cada acción sobre PHI
✗
Registro de actividades de tratamiento (RGPD art. 30)
✗
Política de retención de datos definida (7 años mín.)
✗
DPO notificado de los vectores de fuga detectados
✗
.env.local purgado del historial git