// ===================================================================== // Control de acceso por rol // --------------------------------------------------------------------- // SECCIONES es la única fuente de verdad: de aquí sale el menú que ve // cada persona y el permiso que exige cada endpoint. No puede haber un // menú que muestre algo que la API niega, ni al revés. // ===================================================================== import { registrar } from './bitacora.mjs'; export const GRUPOS = [ { id: 'interno', titulo: 'Uso interno' }, ]; export const SECCIONES = [ { id: 'inicio', titulo: 'Inicio', grupo: null, icono: 'inicio', permisos: ['inicio.ver'], resumen: 'Punto de partida' }, { id: 'oferta', titulo: 'Oferta educativa', grupo: null, icono: 'birrete', permisos: ['oferta.ver'], resumen: 'Los 8 programas con costos, horarios y validez oficial' }, { id: 'requisitos', titulo: 'Requisitos y titulación', grupo: null, icono: 'documento', permisos: ['requisitos.ver'], resumen: 'Documentos por nivel y formas de titularse' }, { id: 'becas', titulo: 'Becas y fechas', grupo: null, icono: 'etiqueta', permisos: ['becas.ver'], resumen: 'Descuentos vigentes y fechas de inscripción' }, { id: 'clases', titulo: 'Links de clases', grupo: null, icono: 'video', permisos: ['clases.ver'], resumen: 'Los enlaces de Google Meet por programa y materia' }, { id: 'preguntas', titulo: 'Preguntas frecuentes', grupo: null, icono: 'libro', permisos: ['kb.ver'], resumen: 'La respuesta correcta a lo que más preguntan' }, { id: 'mensajes', titulo: 'Mensajes listos', grupo: null, icono: 'mensaje', permisos: ['guiones.ver'], resumen: 'Textos para copiar y enviar por WhatsApp' }, { id: 'contacto', titulo: 'Datos del instituto', grupo: null, icono: 'edificio', permisos: ['contacto.ver'], resumen: 'Dirección, teléfonos, horarios y misión' }, // Van bajo "Uso interno" y no bajo "Solo dirección": Equipo lo ve también // administración, y un encabezado que dijera lo contrario sería falso. { id: 'equipo', titulo: 'Equipo', grupo: 'interno', icono: 'personas', permisos: ['equipo.ver'], resumen: 'Quién es quién y qué puede editar cada rol' }, { id: 'bitacora', titulo: 'Bitácora', grupo: 'interno', icono: 'reloj', permisos: ['bitacora.ver'], resumen: 'Quién entró y qué cambió' }, ]; /** ¿El usuario tiene al menos uno de estos permisos? */ export function puede(req, ...claves) { return claves.some((c) => req.permisos?.has(c)); } /** Secciones visibles para este usuario. */ export function navegacionDe(req) { return SECCIONES .filter((s) => puede(req, ...s.permisos)) .map(({ id, titulo, grupo, icono, resumen }) => ({ id, titulo, grupo, icono, resumen })); } // ---- middlewares ----------------------------------------------------- export function exigirSesion(req, res, next) { if (!req.usuario) return res.status(401).json({ error: 'sin-sesion', mensaje: 'Necesitas iniciar sesión.' }); next(); } /** * Exige al menos uno de los permisos indicados. Un intento denegado queda * en la bitácora: es justo lo que dirección quiere poder auditar. */ export function exigir(...claves) { return async (req, res, next) => { if (!req.usuario) return res.status(401).json({ error: 'sin-sesion', mensaje: 'Necesitas iniciar sesión.' }); if (puede(req, ...claves)) return next(); await registrar(req, { accion: 'acceso.denegado', entidad: 'permiso', entidad_id: claves.join('|'), resultado: 'denegado', detalle: `${req.method} ${req.originalUrl}`, }); return res.status(403).json({ error: 'sin-permiso', mensaje: `Tu rol (${req.usuario.rol_nombre}) no puede hacer esto.`, requiere: claves, }); }; }