Wiki / Documentación interna / Arquitectura

Roles, RLS y superadmin

Superadmin no vive en memberships, y su política de lectura de socios nunca ganó una excepción para él — es la decisión de seguridad más importante del proyecto.

Cómo se resuelve un rol

  • memberships (user_id, society_id, role) resuelve society_admin / society_staff / member.
  • superadmin vive en auth.jwt() -> app_metadata -> platform_role, escribible solo por service_role — a propósito, para que nadie pueda auto-ascenderse con un simple UPDATE.
  • Funciones en private (security definer, stable, set search_path=''): is_superadmin(), has_society_role(sid, roles[]), is_society_member(sid), my_society_ids() — siempre envueltas en (select ...) dentro de una política (evaluación única por consulta vía InitPlan, no por fila).
  • Dato clave: la política members_select no incluye is_superadmin(). Un superadmin autenticado como sí mismo no puede leer ninguna ficha de socio. La única vía es el modo soporte.

Dos puertas de entrada

/login (sociedad) y /admin/login (superadmin) son Server Actions y componentes completamente separados (iniciarSesion / iniciarSesionAdmin), con el mismo mensaje genérico de error en ambos. El middleware redirige a quien ya tiene sesión lejos de la puerta equivocada, nunca hacia el destino de la otra.

2FA / MFA

  • TOTP vía Supabase Auth MFA, como módulo activable (mfa) con doble interruptor (global + por sociedad) — ver módulos.
  • Middleware exige AAL2 para society_admin/society_staff si el módulo resuelve activo; para /admin/* (superadmin) es incondicional, sin interruptor de módulo.
  • Códigos de recuperación: tabla mfa_recovery_codes, 10 códigos de 8 caracteres (40 bits), guardados como SHA-256, consumo con FOR UPDATE SKIP LOCKED para evitar doble uso concurrente — cubre una carencia real de Supabase Auth, que no da recovery codes nativos.

Modo soporte: cómo funciona de verdad

No es un flag sobre la sesión del superadmin — es una suplantación auténtica. Se elige así precisamente porque members_select nunca gana una rama para is_superadmin().

Generar el enlace

supabase.auth.admin.generateLink() (API service_role, aislada en src/lib/server/admin/impersonacion.ts).

Canjear al instante

verifyOtp() planta en el navegador las cookies de la persona objetivo — de ahí en adelante, cada acción se ejecuta bajo su RLS, no la del superadmin.

Se registra la sesión

Tabla support_sessions (motivo, inicio, caducidad). registrar_auditoria() la consulta en cada escritura y rellena support_mode/reason/impersonated_by.

Caduca sola

El middleware comprueba la caducidad en cada petición; si ya expiró, hace signOut() inmediato en vez de dejar la sesión atrapada en un MFA imposible de completar (el superadmin no tiene el TOTP del objetivo).

Volver a la cuenta propia

El refresh token original del superadmin viaja cifrado (mismo AES-256-GCM que IBAN/BIC) en la cookie sb-impersonation-return, restaurado al pulsar "Volver a mi cuenta".

Banda roja permanente durante toda la sesión (banda-modo-soporte.tsx), con motivo, hora de caducidad y botón de retorno visibles siempre.

Superadmin: qué gestiona

/admin/sociedades

Alta de sociedades, administradores, modo soporte. No muestra fichas de socios a propósito.

/admin/usuarios

Vista unificada de gestores de todas las sociedades. Cambios de email/nombre van por cola (job_queue), nunca en vivo con service_role.

/admin/copias-seguridad

JSON autocontenido por sociedad, generado en segundo plano. Sin restauración todavía, solo generación y descarga.

/admin/estilos

Tipografía y colores del listado/menú por sociedad, con suelo mínimo de legibilidad validado en servidor.