SIENEP · del navegador a la base

Administración de roles, permisos y categorías

Una administradora necesita que un funcionario consulte estudiantes de un grupo. Crea un rol, elige sus permisos y lo asigna con un alcance. También prepara categorías para ordenar las instancias y los recordatorios. El recorrido sigue esas acciones desde React hasta Spring y PostgreSQL.

Módulo 7 · RF32 a RF40 · React + Spring + PostgreSQL

Requisitos funcionales del recorrido

Fuente oficial Requerimientos de Programación de UTEC, 2026 · p. 6

Los nombres del ejemplo y el número de grupo son ficticios. Podés copiar los fragmentos e imprimir la guía. Se abre sin internet.

Abrir Administración y leer un rol

Sesión → capacidades → ruta protegida → catálogo de roles y permisos

1. Cada pantalla de Administración tiene un permiso de lectura

En el menú de SIENEP, Administración agrupa Roles, Categorías de instancia y Categorías de recordatorio. Si se escribe una de sus URLs directamente, React vuelve a comprobar el permiso de lectura de esa pantalla. El backend también exige permisos en cada endpoint.

rutas.tsx · líneas 266–275

TypeScript
  element: <RutaConPermiso permiso="roles.leer" />,
  children: [{ path: '/administracion/roles', element: <PaginaRoles />, handle: { titulo: 'Roles' } }],
},
{
  element: <RutaConPermiso permiso="categorias_instancia.leer" />,
  children: [{ path: '/administracion/categorias-instancia', element: <PaginaCategoriasInstancia />, handle: { titulo: 'Categorías de instancia' } }],
},
{
  element: <RutaConPermiso permiso="categorias_recordatorio.leer" />,
  children: [{ path: '/administracion/categorias-recordatorio', element: <PaginaCategoriasRecordatorio />, handle: { titulo: 'Categorías de recordatorio' } }],

2. Consultar un rol muestra su tipo y sus permisos

La aplicación distingue Sistema y Personalizado a partir de esSistema. Administrador, Psicopedagogo, Tutor y Docente son los perfiles preexistentes del Módulo 7. Al abrir un rol se cargan los permisos que ya tiene y el catálogo completo para poder ver qué más se le puede asignar.

RolController.java · líneas 43–48

Java
@Operation(summary = "Listar permisos de un rol (RF32)")
@PreAuthorize("hasAuthority('roles.leer')")
@GetMapping("/{id}/permisos")
public ResponseEntity<List<PermisoResponseDto>> listarPermisos(@PathVariable Integer id) {
    return ResponseEntity.ok(rolService.listarPermisosPorRol(id));
}

rolesApi.ts · líneas 49–54

TypeScript
export async function listarCatalogoPermisos(senal?: AbortSignal) {
  return validarPermisos(await clienteHttp.get('/api/permisos', {
    senal,
    esperar: 'json',
  }))
}

3. Las capacidades corresponden a la sesión actual

El frontend consulta /auth/capacidades para mostrar los accesos y habilitar acciones. Un perfil con roles.leer puede consultar los roles. Guardar cambios exige la autoridad correspondiente y se vuelve a comprobar antes de enviar la operación.

capacidadesApi.ts · líneas 23–30

TypeScript
export async function obtenerCapacidades(userId: number, senal: AbortSignal) {
  const datos = await clienteHttp.get<unknown>('/auth/capacidades', {
    senal,
    cache: 'no-store',
    esperar: 'json',
  })
  return validarCapacidades(datos, userId)
}

Crear un rol y elegir qué acciones concede

Nombre y descripción → POST del rol → selección completa → PUT de permisos

1. Un rol personalizado nace activo

Una administradora entra a Roles y pulsa Nuevo rol. En el ejemplo lo llama «Seguimiento de grupo». React envía nombre y descripción. Spring exige roles.crear, comprueba que el nombre esté disponible y guarda el rol como activo y personalizado. Un nombre duplicado genera 409.

rolesApi.ts · líneas 56–64

TypeScript
export async function guardarRol(datos: RolEntrada, idRol?: number, senal?: AbortSignal) {
  const cuerpo = { nombre: datos.nombre, descripcion: datos.descripcion }
  const respuesta = idRol
    ? await clienteHttp.put(`/api/roles/${idRol}`, cuerpo, { senal, esperar: 'json' })
    : await clienteHttp.post('/api/roles', cuerpo, { senal, esperar: 'json' })
  const rol = validarRol(respuesta)
  exigir(idRol ? rol.idRol === idRol : !rol.esSistema && rol.estActivo)
  return rol
}

RolService.java · líneas 54–67

Java
@Transactional
public RolResponseDto crear(String nombre, String descripcion) {
    validarNombreDisponible(nombre, null);
    Rol r = new Rol();
    r.setNombre(nombre);
    r.setDescripcion(descripcion);
    r.setEsSistema(false);
    r.setEstActivo(true);
    r = guardar(r);

    auditDispatcher.registrar("roles", "CREATE",
            String.valueOf(r.getIdRol()), Map.of("nombre", nombre));
    return RolResponseDto.from(r);
}

2. Guardar permisos fija el conjunto completo

Se elige, por ejemplo, estudiantes.leer. El frontend envía los identificadores de todos los permisos que deben quedar en el rol. La API exige roles.modificar, valida los IDs y, dentro de una transacción, elimina de rol_permiso los vínculos retirados e inserta los nuevos. Quitar una marca en el formulario retira ese vínculo cuando se guarda.

RolController.java · líneas 56–62

Java
@PreAuthorize("hasAuthority('roles.modificar')")
@PutMapping("/{id}/permisos")
public ResponseEntity<List<PermisoResponseDto>> fijarPermisos(
        @PathVariable Integer id,
        @Valid @RequestBody RolPermisosUpdateDto dto) {
    return ResponseEntity.ok(rolService.fijarPermisos(id, dto.getIdsPermisos()));
}

rolesApi.ts · líneas 66–77

TypeScript
export async function guardarPermisos(idRol: number, idsPermisos: number[], senal?: AbortSignal) {
  exigir(idsPermisos.every(id) && new Set(idsPermisos).size === idsPermisos.length)
  const respuesta = await clienteHttp.put(`/api/roles/${idRol}/permisos`, {
    idsPermisos: [...idsPermisos],
  }, { senal, esperar: 'json' })
  const permisos = validarPermisos(respuesta)
  exigir(
    permisos.length === idsPermisos.length &&
    permisos.every((p) => idsPermisos.includes(p.idPermiso)),
  )
  return permisos
}

RolService.java · líneas 119–134

Java
Set<Integer> nuevosIds = new HashSet<>(idsPermisos);

actuales.stream()
        .filter(rp -> !nuevosIds.contains(rp.getId().getIdPermiso()))
        .forEach(rolPermisoRepository::delete);

Rol rol = buscarPorId(idRol);
nuevosPermisos.stream()
        .filter(p -> !actualesIds.contains(p.getIdPermiso()))
        .forEach(p -> {
            RolPermiso rp = new RolPermiso();
            rp.setId(new RolPermisoId(idRol, p.getIdPermiso()));
            rp.setRol(rol);
            rp.setPermiso(p);
            rolPermisoRepository.save(rp);
        });

3. Metadatos y permisos se guardan por separado

Si se guarda el nombre o la descripción y luego falla la actualización de permisos, el primer cambio puede haber quedado aplicado. El diálogo informa el guardado parcial y vuelve a consultar el rol y sus permisos antes de seguir. Para un rol de sistema se pueden configurar descripción y permisos con la autorización correspondiente. Su nombre y su baja quedan protegidos. Un rol personalizado se puede dar de baja con roles.eliminar. Esa baja afecta a quienes lo tenían asignado porque el backend toma solo roles activos.

DialogoRol.tsx · líneas 252–268

TypeScript
} catch (err) {
  if (!operacion.vigente()) return
  if (fase === 'permisos' && datosGuardados) {
    setParcial(true)
    setReconciliado(false)
    setError(MENSAJE_PARCIAL)
    try {
      const [actual, permisos] = await Promise.all([
        obtenerRol(confirmado!.idRol, operacion.senal()),
        listarPermisosRol(confirmado!.idRol, operacion.senal()),
      ])
      if (!operacion.vigente()) return
      setBase(actual)
      setNombre(actual.nombre)
      setDescripcion(actual.descripcion ?? '')
      setOriginales(permisos.map((p) => p.idPermiso))
      setReconciliado(true)

RolService.java · líneas 71–80

Java
public RolResponseDto actualizar(Integer id, String nombre, String descripcion) {
    Rol r = buscarPorId(id);
    if (Boolean.TRUE.equals(r.getEsSistema()) && nombre != null && !nombre.equals(r.getNombre())) {
        throw new IllegalStateException("No se puede renombrar un rol de sistema: " + r.getNombre());
    }
    if (nombre != null && !nombre.equals(r.getNombre())) administradorProtection.validarRetiroRol(r);
    if (nombre != null) validarNombreDisponible(nombre, id);
    if (nombre      != null) r.setNombre(nombre);
    if (descripcion != null) r.setDescripcion(descripcion);
    r = guardar(r);

Dar el rol a un funcionario dentro de un alcance

Ficha del funcionario → rol activo → ITR, carrera o grupo → autorización efectiva

1. La asignación reúne persona, rol y alcance

Desde la ficha de un funcionario activo, se abre su sección de roles. Para este ejemplo se asigna «Seguimiento de grupo» al grupo 18. El formulario ofrece un alcance de tipo ITR, carrera o grupo según los permisos de lectura del operador. Envía el ID del funcionario, el del rol y un único campo de alcance.

asignacionesApi.ts · líneas 35–53

TypeScript
export async function crearAsignacion(entrada: AsignacionEntrada, senal?: AbortSignal) {
  const cuerpo: AsignacionEntrada = {
    idUsuario: entrada.idUsuario,
    idRol: entrada.idRol,
  }
  const campos: TipoAlcance[] = ['idItr', 'idCarrera', 'idGrupo']
  const presentes = campos.filter((campo) => entrada[campo] !== undefined)
  exigir(presentes.length === 1 && id(entrada.idUsuario) && id(entrada.idRol))
  const campo = presentes[0]
  exigir(id(entrada[campo]))
  cuerpo[campo] = entrada[campo]
  const datos = await clienteHttp.post('/api/asignaciones', cuerpo, {
    senal,
    esperar: 'json',
  })
  const asignacion = validarAsignacion(datos)
  exigir(asignacion.estActivo && coincideAsignacion(asignacion, cuerpo))
  return asignacion
}

Spring protege el POST con asignaciones.crear. El servicio valida que el funcionario y el rol estén activos, que las referencias existan y que no se duplique una asignación activa del mismo rol y alcance. Si recibe varias dimensiones, valida también que sean compatibles.

2. El permiso indica qué se puede hacer y el alcance indica sobre qué datos

En cada petición autenticada, el backend carga las autoridades desde las asignaciones y roles efectivos. En operaciones que aplican alcance por grupo, reúne los grupos autorizados por las asignaciones de cada permiso. Si hacen falta varios permisos, usa los grupos que cumplen todos. Con el rol del ejemplo como única autorización relevante, el funcionario puede consultar estudiantes del grupo 18. El grupo 19 queda fuera de su alcance.

UserDetailsServiceImpl.java · líneas 40–56

Java
List<Asignacion> asignaciones = asignacionRepository
        .findAsignacionesEfectivas(usuario.getIdUsuario());

List<SimpleGrantedAuthority> authorities = new ArrayList<>();

Set<Integer> rolIds = new LinkedHashSet<>();
for (Asignacion a : asignaciones) {
    if (rolIds.add(a.getRol().getIdRol())) {
        authorities.add(new SimpleGrantedAuthority(
                "ROLE_" + a.getRol().getNombre().replace(" ", "_")));
    }
}

if (!rolIds.isEmpty()) {
    rolPermisoRepository.findByRolIdsWithPermiso(rolIds).forEach(rp ->
            authorities.add(new SimpleGrantedAuthority(rp.getPermiso().getCodigo())));
}

ScopeService.java · líneas 23–40

Java
public Set<Integer> getGruposPermitidos(Integer actorId, boolean isAdmin, String... permisos) {
    if (isAdmin) return null;
    if (permisos.length == 0) throw new IllegalArgumentException("Se requiere el permiso de la operación");
    Set<Integer> resultado = null;
    for (String permiso : permisos) {
        if (permiso == null || permiso.isBlank()) throw new IllegalArgumentException("Permiso inválido");
        Set<Integer> grupos = new LinkedHashSet<>();
        for (Asignacion a : asignacionRepository.findAsignacionesEfectivasConPermiso(actorId, permiso)) {
            List<Grupo> candidatos = a.getGrupo() != null ? List.of(a.getGrupo())
                    : a.getCarrera() != null ? grupoRepository.findByCarrera_IdCarrera(a.getCarrera().getIdCarrera())
                    : a.getItr() != null ? grupoRepository.findByItr_IdItr(a.getItr().getIdItr()) : List.of();
            candidatos.stream().filter(g -> coincide(a, g)).forEach(g -> grupos.add(g.getIdGrupo()));
        }
        if (resultado == null) resultado = grupos;
        else resultado.retainAll(grupos);
    }
    return resultado == null ? Set.of() : resultado;
}

3. Revocar una asignación le quita ese vínculo al funcionario

Revocar marca inactiva la asignación. El rol y sus permisos siguen disponibles para los demás funcionarios que lo tengan. Si otro rol activo concede la misma acción y alcance, ese acceso puede seguir vigente. El backend protege la asignación propia de Administrador y el último administrador activo.

AsignacionService.java · líneas 102–110

Java
@Transactional
public void revocar(Integer idAsignacion) {
    Asignacion a = asignacionRepository.findById(idAsignacion)
            .orElseThrow(() -> new IllegalArgumentException("Asignación no encontrada: " + idAsignacion));
    administradorProtection.validarRevocacion(a);
    a.setEstActivo(false);
    asignacionRepository.save(a);
    auditDispatcher.registrar("asignaciones", "DELETE", String.valueOf(idAsignacion), null);
}

AdministradorProtectionService.java · líneas 23–34

Java
public void validarRevocacion(Asignacion asignacion) {
    Rol administrador = roles.bloquearAdministrador().orElse(null);
    if (administrador == null || !administrador.getIdRol().equals(asignacion.getRol().getIdRol())
            || !Boolean.TRUE.equals(asignacion.getEstActivo())) return;
    var auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth != null && asignacion.getUsuario().getIdUsuario().equals(auth.getPrincipal())) {
        throw new IllegalStateException("No se puede quitar la propia asignación de Administrador");
    }
    if (cantidad(administrador, -1, -1) > 0 && cantidad(administrador, -1, asignacion.getIdAsignacion()) == 0) {
        throw new IllegalStateException("No se puede quitar al último administrador activo");
    }
}

Administrar los dos catálogos de categorías

Pantalla compartida → adaptador de la familia → POST o PUT → catálogo actualizado

1. Instancias y recordatorios tienen catálogos distintos

React usa la misma pantalla PaginaCategorias para ambas familias. Cada una conecta su propio catálogo, con sus IDs, endpoints y permisos. El listado permite buscar por nombre o descripción y filtrar por estado.

PaginaCategoriasInstancia.tsx · líneas 1–6

TypeScript
import PaginaCategorias from '../../../compartido/categorias/PaginaCategorias.tsx'
import { catalogoInstancia } from '../catalogo.ts'

export default function PaginaCategoriasInstancia() {
  return <PaginaCategorias catalogo={catalogoInstancia} />
}

PaginaCategoriasRecordatorio.tsx · líneas 1–6

TypeScript
import PaginaCategorias from '../../../compartido/categorias/PaginaCategorias.tsx'
import { catalogoRecordatorio } from '../catalogo.ts'

export default function PaginaCategoriasRecordatorio() {
  return <PaginaCategorias catalogo={catalogoRecordatorio} />
}

2. Agregar o editar guarda nombre y descripción

En Categorías de instancia, se puede crear «Seguimiento académico». En Categorías de recordatorio, «Revisión de avance». El diálogo valida el formulario, comprueba lectura más el permiso de alta o modificación y llama al adaptador de la familia. La API valida los datos y comprueba duplicados dentro de ese catálogo. Una categoría nueva se guarda activa. Editar conserva su ID y las referencias que ya lo usan.

DialogoCategoria.tsx · líneas 159–176

TypeScript
try {
  const permisos = await refrescar()
  if (!operacion.vigente()) return
  const autoridad = id === null ? crear : modificar
  if (!permisos?.includes(lectura) || !permisos.includes(autoridad)) {
    setRechazada(true)
    setError('No tenés permiso para realizar esta acción')
    return
  }
  enviada = true
  const datos = { nombre: nombre.trim(), descripcion }
  const actual = id === null
    ? await catalogo.crear(datos, operacion.senal())
    : await catalogo.actualizar!(base!, datos, operacion.senal())
  if (!operacion.vigente()) return
  onGuardado(actual, id === null
    ? 'Categoría creada correctamente' : 'Categoría actualizada correctamente')
  onCerrar()
HTTP · rutas relativas a cada catálogo
Instancias     /api/categorias-instancia
Recordatorios  /api/categorias-recordatorio

GET     /               Listar
POST    /               Agregar
PUT     /{id}           Modificar
DELETE  /{id}           Dar de baja

3. Leer el catálogo también sirve para crear una instancia

Un caso real del proyecto fue Psicopedagogo. El formulario de instancia necesitaba cargar el catálogo para elegir una categoría. Se agregó categorias_instancia.leer al rol para permitir esa consulta. Los permisos .crear, .modificar y .eliminar del catálogo se comprueban por separado.

permiso_psicopedagogo_categorias_m7.sql · líneas 8–12

SQL
INSERT INTO rol_permiso (id_rol, id_permiso)
SELECT r.id_rol, p.id_permiso
FROM roles r JOIN permisos p ON p.codigo = 'categorias_instancia.leer'
WHERE r.nombre = 'Psicopedagogo'
ON CONFLICT (id_rol, id_permiso) DO NOTHING;

Dar de baja con control de uso

Confirmar → DELETE → comprobar referencias activas → guardar la baja → auditoría

1. La API decide si la categoría se puede desactivar

Después de confirmar la baja, React llama al DELETE de la familia. Spring exige el permiso .eliminar correspondiente. El servicio bloquea la fila de la categoría para revisar el uso antes de cambiar su estado. Una categoría de instancia con instancias activas, o una de recordatorio con recordatorios activos, produce 409.

CategoriaInstanciaService.java · líneas 64–75

Java
@Transactional
public void desactivar(Integer id) {
    CategoriaInstancia c = repo.bloquearPorId(id)
            .orElseThrow(() -> new IllegalArgumentException("Categoría de instancia no encontrada: " + id));
    if (referencias.existsByCategoria_IdCategoriaInstanciaAndEstActivoTrue(id)) {
        throw new IllegalStateException("La categoría de instancia está en uso por instancias activas");
    }
    c.setEstActivo(false);
    repo.save(c);

    auditDispatcher.registrar("categorias_instancia", "DELETE", String.valueOf(id), null);
}

CategoriaRecordatorioService.java · líneas 67–78

Java
@Transactional
public void desactivar(Integer id) {
    CategoriaRecordatorio c = repo.bloquearPorId(id)
            .orElseThrow(() -> new IllegalArgumentException("Categoría de recordatorio no encontrada: " + id));
    if (referencias.existsByCategoria_IdCategoriaRecordatorioAndEstActivoTrue(id)) {
        throw new IllegalStateException("La categoría de recordatorio está en uso por recordatorios activos");
    }
    c.setEstActivo(false);
    repo.save(c);

    auditDispatcher.registrar("categorias_recordatorio", "DELETE", String.valueOf(id), null);
}

2. Una baja lógica conserva el historial

Cuando la baja procede, estActivo pasa a falso y el DELETE responde 204. La categoría conserva su ID y sus referencias históricas. Se puede consultar en el catálogo administrativo filtrando por inactivas. Si se intenta usarla como una nueva categoría en el módulo de origen, el backend exige que esté activa.

InstanciaService.java · líneas 450–458

Java
private CategoriaInstancia resolverCategoriaActiva(Integer id) {
    CategoriaInstancia categoria = categoriaRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("Categoría de instancia no encontrada: " + id));
    if (!Boolean.TRUE.equals(categoria.getEstActivo())) {
        throw new IllegalArgumentException("La categoría de instancia no está activa");
    }
    return categoriaRepository.bloquearActivaPorId(id)
            .orElseThrow(() -> new IllegalArgumentException("La categoría de instancia no está activa"));
}

3. La auditoría se programa después de confirmar la transacción

Los servicios registran cambios de roles, permisos, asignaciones y categorías con la entidad y la acción. El componente de auditoría captura el actor autenticado y programa el registro para después del commit. Así, una operación que se revierte no deja una auditoría de éxito.

AdministracionAuditDispatcher.java · líneas 19–27

Java
public void registrar(String entidad, String accion, String idEntidad, Map<String, Object> detalle) {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    Integer actorId = authentication != null && authentication.isAuthenticated()
            && authentication.getPrincipal() instanceof Integer id ? id : null;
    Map<String, Object> capturedDetail = detalle == null ? null : Map.copyOf(detalle);

    dispatcher.afterCommit(actorId, entidad + "." + accion, List.of(() ->
            auditoriaService.registrarConActor(entidad, accion, idEntidad, actorId, capturedDetail)));
}

Para contarlo en la defensa

“Un permiso habilita una acción, un rol agrupa permisos y una asignación relaciona el rol con un funcionario y su alcance. React usa las capacidades para mostrar las acciones disponibles y Spring vuelve a verificarlas. Para clasificar instancias y recordatorios hay dos catálogos. La baja conserva el registro y se bloquea mientras haya elementos activos que dependan de la categoría.”

Archivos revisados

Los fragmentos y números de línea corresponden a las copias de frontend y backend del paquete del 06/11/2026. El alcance por RF se cotejó con la entrega del Módulo 7.