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.
Requisitos funcionales del recorrido
RF32Gestión de RolesRF33Roles PreexistentesRF34Roles PersonalizadosRF35Agregar Categorías de InstanciasRF36Eliminar Categorías de InstanciasRF37Modificar Categorías de InstanciasRF38Agregar Categorías de RecordatoriosRF39Eliminar Categorías de RecordatoriosRF40Modificar Categorías de Recordatorios
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
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
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
@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
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
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
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
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
@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
@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
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
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
} 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
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
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
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
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
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
@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
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
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
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
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
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()Instancias /api/categorias-instancia
Recordatorios /api/categorias-recordatorio
GET / Listar
POST / Agregar
PUT /{id} Modificar
DELETE /{id} Dar de baja3. 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
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
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
@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
@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
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
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.
frontend/src/aplicacion/rutas.tsxbackend/src/main/java/edu/utec/sienep/controller/RolController.javafrontend/src/modulos/roles/api/rolesApi.tsfrontend/src/compartido/permisos/api/capacidadesApi.tsbackend/src/main/java/edu/utec/sienep/service/RolService.javafrontend/src/modulos/roles/componentes/DialogoRol.tsxfrontend/src/modulos/roles/api/asignacionesApi.tsbackend/src/main/java/edu/utec/sienep/security/UserDetailsServiceImpl.javabackend/src/main/java/edu/utec/sienep/service/ScopeService.javabackend/src/main/java/edu/utec/sienep/service/AsignacionService.javabackend/src/main/java/edu/utec/sienep/service/AdministradorProtectionService.javafrontend/src/modulos/categoriasInstancia/paginas/PaginaCategoriasInstancia.tsxfrontend/src/modulos/categoriasRecordatorio/paginas/PaginaCategoriasRecordatorio.tsxfrontend/src/compartido/categorias/DialogoCategoria.tsxbackend/src/main/resources/db/permiso_psicopedagogo_categorias_m7.sqlbackend/src/main/java/edu/utec/sienep/service/CategoriaInstanciaService.javabackend/src/main/java/edu/utec/sienep/service/CategoriaRecordatorioService.javabackend/src/main/java/edu/utec/sienep/service/InstanciaService.javabackend/src/main/java/edu/utec/sienep/service/AdministracionAuditDispatcher.java