🏥
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