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 | 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');
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_)
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
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
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
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