Sistema dinámico de permisos usando Spring Security

En esta página se presenta un esquema de seguridad de una aplicación basada en Spring, con el módulo Spring Security.

Requerimientos

En vez de la rígida estructura de permisos ejemplificada en numerosos sitios web, donde el alcance de un rol ya está predeterminado, se plantea un esquema donde se pueden crear grupos o roles a voluntad y asignarles uno o varios permisos según se desee.

Específicamente se necesita:

  • Que un usuario pertenezca a un grupo o rol
  • Que un administrador del sistema pueda crear grupos o roles y asignarles permisos basados en las acciones que se desean permitir para cada uno de estos usuarios.
  • Que la pantalla de otorgamiento de tales permisos se genere en forma dinámica a partir de los métodos de los Controllers de la aplicación, de manera tal que no haga falta actualizar la pantalla de permisos con los nuevos permisos a otorgar, sino que esta se genere automáticamente.
  • Que el otorgamiento de permisos sea lo suficientemente granular y específico para que al usuario se le otorguen permisos para realizar solamente las acciones permitidas, y a la vez de entendimiento simple; si una acción está permitida, se asume que tal acción se podrá aplicar sobre todas las entidades que la acción afecta.

Diseño de la solución

  • La aplicación estará desarrollada en Java y usará JDK 1.7 o superior. Usará patrones MVC y estará basada en Spring.
  • Para la persistencia de las entidades de negocio, se utilizará una base de datos relacional y el sistema Hibernate como ORM.
  • El módulo Spring Security será utilizado para proveer controles de acceso a las acciones. Los permisos se otorgarán en base a la existencia o no de entidades en la base de datos que representen los permisos de la aplicación.

Desarrollo

Asumimos que ya se cuenta con el esquema básico de una aplicación en Spring. En el artículo Aplicación básica en Spring damos una explicación de cómo hacer eso.

A continuación se presenta el código de un Controller de una aplicación web basada en Spring. Se hace un uso intensivo de Java Annotations, para lograr los cometidos expresados en los requerimientos:

  • Una Annotation de Spring Security informa el permiso específico que representa la acción que realiza el método del Controller.
  • Una Annotation especialmente desarrollada para denominar al Controller y así usarlo como agrupador de permisos a otorgar.
  • Una Annotation especialmente desarrollada describe brevemente esta acción y reproduce el nombre del permiso, para ser usado luego al asignar el permiso al rol.
@DescripcionClase(value="Permisos")
@Controller
@RequestMapping("permisos")
public class PermisosController extends AppController
{
	static Logger log = Logger.getLogger(PermisosController.class);

	private RequestMappingHandlerMapping handlerMapping;
	@Autowired
	private PermissionService permissionService;
	
	
	@Autowired
	public void setRequestHandlerMapping(RequestMappingHandlerMapping handlerMapping)
	{
		this.handlerMapping=handlerMapping;
	}
	@Descripcion(value="Mostrar menu de permisos",permission="ROLE_PERMISOS_MOSTRAR_MENU")
	@PreAuthorize("isAuthenticated() and (hasRole('ROLE_ADMIN') or hasRole('ROLE_PERMISOS_MOSTRAR_MENU'))")
	@RequestMapping({"/","/index"})
	public ModelAndView mostrarMenu(Model model) 
	{
		ModelAndView modelo=new ModelAndView("permisos/index");
		return modelo;
	}
	@Descripcion(value="Modificar permisos",permission="ROLE_PERMISOS_MODIFICAR")
	@RequestMapping(value="/listar",method=RequestMethod.GET)
	@PreAuthorize("isAuthenticated() and (hasRole('ROLE_ADMIN') or hasRole('ROLE_PERMISOS_MODIFICAR'))")
	public ModelAndView listarPermisos()
	{
		ModelAndView modelo=new ModelAndView("permisos/listar_permisos");
		// Por reflection, voy a obtener todos los controllers, todos
		// los mappings, y mandarlos a la vista. Despues un javascript
		// va a invocar, para cada uno de los permisos, un metodo para pedir
		// el permiso especifico y poner un grafiquito de tilde o de equis
		Map<NombreClase,List<PermisoEnVista>> controllers=new LinkedHashMap<NombreClase,List<PermisoEnVista>>();
		Map<RequestMappingInfo, HandlerMethod> p=this.handlerMapping.getHandlerMethods();
		for(Map.Entry<RequestMappingInfo, HandlerMethod> entry:p.entrySet())
		{
			// Ahora para cada metodo averiguo el nombre del controller, del 
			// metodo y el tipo de request.
			String controllerName=entry.getValue().getMethod().getDeclaringClass().getName();
			NombreClase n=new NombreClase();
			n.setNombreClase(controllerName);
			if(!controllers.containsKey(n))
			{
				// Si en el mapa Controller ya tengo una clave cuyo nombre
				// es el nombre de esta clase, pido la lista que el mapa controllers
				// tiene y agrego un item.
				List<PermisoEnVista> metodos=new LinkedList<PermisoEnVista>();
				// Pero en vez del nombre del Controller, quiero el valor
				// de la anotacion DescripcionClase.
				DescripcionClase d=entry.getValue().getMethod().getDeclaringClass().getAnnotation(DescripcionClase.class);
				if(d!=null)
				{
					n.setDescripcionClase(d.value());
				}
				else
				{
					n.setDescripcionClase(controllerName);
				}
				controllers.put(n, metodos);
			}
			// Si el metodo que encontre tiene la anotacion @Descripcion,
			// escribo su valor y conservo el nombre del rol.
			Descripcion d=entry.getValue().getMethod().getAnnotation(Descripcion.class);
			if(d!=null)
			{
				PermisoEnVista permiso=new PermisoEnVista();
				permiso.setDescripcionPermiso(d.value());
				permiso.setNombrePermiso(d.permission());
				controllers.get(n).add(permiso);
			}
		}
        modelo.addObject("controllers",controllers);
        // Ahora vamos a buscar los grupos que se hayan creado.
        List<Group> grupos=permissionService.listAllGroups();
        modelo.addObject("groups",grupos);
		return modelo;
	}
	@RequestMapping(value="/informar",method=RequestMethod.GET)
	@PreAuthorize("isAuthenticated() and (hasRole('ROLE_ADMIN') or hasRole('ROLE_PERMISOS_MODIFICAR'))")
	public @ResponseBody String informarPermiso(@RequestParam("permiso") String permiso)
	{
		// Informa si el permiso que me dan existe.
		// Ahora consulto la base de datos.
		try
		{
			log.trace("Me llamaron con el permiso "+permiso);
			StringTokenizer t=new StringTokenizer(permiso,"-");
			String id=t.nextToken();
			log.trace("Tengo el id? "+id);
			String permisoBuscar=t.nextToken();
			log.trace("Me llamaron, tengo que buscar el permiso "+permisoBuscar+" del grupo "+id);
			// Primero busco el grupo
			Group grupo=permissionService.findGroupById(Integer.parseInt(id));
			if(grupo!=null)
			{
				log.trace("Encontre el grupo, averiguo si tiene el permiso");
				Set<Permission> s=grupo.getPermissions();
				for(Permission pp:s)
				{
					if(pp.getAuthority().equals(permisoBuscar))
						return "true";
				}
			}
		}
		catch(Exception e)
		{
			e.printStackTrace();
		}
		return "false";
	}
	@RequestMapping(value="/modificar",method=RequestMethod.GET)
	@PreAuthorize("isAuthenticated() and (hasRole('ROLE_ADMIN') or hasRole('ROLE_PERMISOS_MODIFICAR'))")
	public @ResponseBody String modificarPermiso(@RequestParam("permiso") String permiso)
	{
		// Informa si el permiso que me dan existe.
		// Ahora consulto la base de datos.
		try
		{
			StringTokenizer t=new StringTokenizer(permiso,"-");
			String id=t.nextToken();
			String permisoBuscar=t.nextToken();
			Group grupo=permissionService.findGroupById(Integer.parseInt(id));
			if(grupo!=null)
			{
				return permissionService.grantOrRevokePermission(grupo, permisoBuscar);
			}
		}
		catch(Exception e)
		{
			e.printStackTrace();
		}
		return "error";
	}
}

Fíjense cómo funciona esto:

  • Entro a la url /permisos/listar. Eso invoca el método listarPermisos, el cual, para cada uno de los grupos de usuarios que existen, genera una lista de todos los métodos que hayamos etiquetado con las Annotations en los controllers. De cada uno de los métodos, busca el valor de las Annotations para mostrarlo para representar el permiso que se otorga. Lo que se logra con esto, es que cada método esté individualizado como un permiso para permitir acceder o no. De nosotros, en el desarrollo de la aplicación, depende crear métodos que representen exactamente qué acciones los usuarios están autorizados a realizar.
  • La página generada al mostrar el View de este método muestra cada uno de los items y les asigna la clase “permiso”.
  • Un javascript lee todos los items de la clase “permiso” cuando se carga la página y para cada uno de ellos invoca la URL permisos/informar, la cual método informarPermiso, que es el que consulta la base de datos y devuelve si el grupo contiene el permiso o no.
  • Otro javascript detecta el click sobre el grafiquito que acompaña a cada permiso (un Tick si el permiso está autorizado o una X de lo contrario), y llama a la URL /permisos/modificar, el cual invoca el método modificarPermiso, que crea el registro en la base de datos.

Vamos a ilustrar esto.

A continuación muestro el código de la View relacionada con el método listarPermisos.

<%@taglib uri="http://www.springframework.org/tags" prefix="spring"%>
<%@taglib uri="http://www.springframework.org/tags/form" prefix="form"%>
<%@taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>

<link rel="stylesheet" type="text/css" href="${pageContext.request.contextPath}/css/jquery-ui.css" />
<script type="text/javascript" src="${pageContext.request.contextPath}/js/jquery.js"></script>
<script type="text/javascript" src="${pageContext.request.contextPath}/js/jquery-ui.js"></script>
<%@include file="/WEB-INF/jsp/permisos/listar_permisos.js" %>

<h1>Permisos</h1>

<table>
<tr><th>M&oacute;dulo</th><th>Permiso</th>
<%
// Aca recorro la lista de grupos
%>
<c:forEach items="${groups}" var="group">
<th><c:out value="${group.groupName}"/></th>
</c:forEach>
</tr>
<c:forEach items="${controllers}" var="controllerItem">
	<c:forEach items="${controllerItem.value}" var="metodo">
		<tr>
		<td><c:out value="${controllerItem.key.descripcionClase}"/>
		</td>
		<td><c:out value="${metodo.descripcionPermiso}"/></td>
		<%
		// aca creo casillitas para cada uno de los permisos por grupo.
		// Voy a crear un td donde se muestre una imagen y 
		// al principio todas van a tener el gif animado de la ruedita
		%>
		<c:forEach items="${groups}" var="group">
			<td class="permiso" id="<c:out value="${group.id}"/>-<c:out value="${metodo.nombrePermiso}"/>" onclick="cambiarPermiso(this)">
			<img src="${pageContext.request.contextPath}/img/ui-anim_basic_16x16.gif">
			</td>
		</c:forEach>
		</tr>
	</c:forEach>
</c:forEach>
</table>

En este código se observa el Class permiso asignado a cada item de permisos. Recordemos que los permisos se asignan al grupo de usuarios. Este sistema se puede extender fácilmente para asignar a usuarios específicos aquellos permisos que se deseen a pesar de no pertenecer al grupo que los tiene. Un “Workaround” para esto sería crear un grupo especial solamente para este usuario, y asignar todos los permisos ahí.

Ahora veamos el javascript:

<script type="text/javascript">
$(document).ready(function()
{
	BuscarPermisos();
});
/**
 * Busca los permisos de los distintos perfiles.
 * Recorre todos los td de la clase permiso y para cada uno de ellos
 * hace un request ajax donde va y busca que dibujito tiene que mostrar,
 * si el de la X o el del tick.
 * @returns
 */
function BuscarPermisos()
{
	var prefijoPath="${pageContext.request.contextPath}";
	$(".permiso").each( function()
	{
		var permisoBuscar=$(this).attr("id");
		var url=prefijoPath+"/permisos/informar?permiso="+permisoBuscar;
		// Para cada uno, pido un json
		$.getJSON(url,
		{
		},
		function(permiso)
		{
			if(permiso !== null)
			{
				if(permiso)
					$('#'+permisoBuscar).html('<img src="'+prefijoPath+'/img/tick.png">');
				else
					$('#'+permisoBuscar).html('<img src="'+prefijoPath+'/img/cross.png">');
			}
		});
	});
}
/**
 * Recibe sobre que TD se hizo click. Con eso busco el id, y mando un request
 * al sistema para modificar el permiso. 
 * @param item
 * @returns
 */
function cambiarPermiso(item)
{
	var prefijoPath="${pageContext.request.contextPath}";
	var permisoBuscar=item.id;
	var url=prefijoPath+"/permisos/modificar?permiso="+permisoBuscar;
	// Para cada uno, pido un json
	$.getJSON(url,
	{
	},
	function(permiso)
	{
		if(permiso !== null)
		{
			if(permiso)
				$('#'+permisoBuscar).html('<img src="'+prefijoPath+'/img/tick.png">');
			else
				$('#'+permisoBuscar).html('<img src="'+prefijoPath+'/img/cross.png">');
		}
	});
}
</script>

Aquí en este javascript se ve lo que decíamos hace un rato. Si el método permiso/informar devuelve un contenido no nulo, entonces el permiso está asignado al grupo. Cuando se hace click en el gráfico, se envía el mensaje para modifcar el permiso. Para quitar el permiso, se elimina el correspondiente registro de la base de datos, y para agregarlo, se crea.

¿Cómo se crean los grupos? Fácil. Lo que haríamos sería crear un “controller de instalación” el cual crea grupos básicos y crea el vital usuario administrador, al cual le asigna el permiso ROLE_ADMIN. Noten que el Controller tiene hard-codeado que a la pantalla de Permisos puede acceder cualquiera que tenga el permiso ROLE_ADMIN aparte del permiso específico para mostrar esa pantalla.

Por último vemos el código de la Annotation. La annotation en sí es una herramienta de documentación, y el propósito de ellas es buscarlas usando Reflection para tomar acciones sobre los items que están documentados por ellas.

Aquí viene la annotation DescripcionClase. Como se puede ver en el Controller, mediante una API se solicitan todos los HandlerMethod, o sea, aquellos métodos de los Controllers que están relacionados con una URL y a los que se puede llegar desde la web. A cada uno de esos Controllers se le busca el valor de DescripcionClase, la cual sirve para agrupar por rubro o categoría los permisos a otorgar.

package permisos.annotations;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DescripcionClase 
{
        String value() default "Clase no especificada";
}

Aquí se presenta la otra Annotation, Descripcion. Esta annotation describe cada método: qué permiso se otorga en la base de datos, y qué descripción de texto se puede colocar.

package permisos.annotations;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Descripcion 
{
        String value() ;
        String permission();
}

En el Controller se puede observar que al recorrer la lista de HandlerMethods, se obtiene una entidad que contiene el nombre de la clase y el método. Lo que se hace con esto, es almacenar el nombre de la clase en un objeto de tipo NombreClase para facilitar luego su búsqueda, para saber que si ya tenemos almacenado el nombre de ese controller, entonces no lo agregamos y nos concentramos solamente en el nombre del método para agregar a la lista de permisos.

A continuación se coloca el código de NombreClase:

package permisos.annotations;

public class NombreClase 
{
        private String nombreClase;
        private String descripcionClase;

        public NombreClase()
        {

        }
        public NombreClase(String nombreClase,String descripcionClase)
        {
                this.nombreClase=nombreClase;
                this.descripcionClase=descripcionClase;
        }

        public void setNombreClase(String n)
        {
                nombreClase=n;
        }
        public String getNombreClase()
        {
                return nombreClase;
        }
        public void setDescripcionClase(String d)
        {
                descripcionClase=d;
        }
        public String getDescripcionClase()
        {
                return descripcionClase;
        }
        public boolean equals(Object otro)
        {
                if(nombreClase==((NombreClase)otro).nombreClase && descripcionClase==((NombreClase)otro).descripcionClase)
                        return true;
                else
                        return false;
        }
}

Por último, para mandar la lista de clases y métodos a la vista, se introducen los datos en la clase PermisoEnVista.

package permisos.annotations;

/**
 * Contiene el nombre y la descripcion del permiso que hay que mostrar en la vista,
 * corresponde al permiso obtenido de leer la Annotation en el Controller
 * @author daxcurson
 *
 */
public class PermisoEnVista 
{
        private String nombrePermiso;
        private String descripcionPermiso;
        public String getNombrePermiso() 
        {
                return nombrePermiso;
        }
        public void setNombrePermiso(String nombrePermiso) 
        {
                this.nombrePermiso = nombrePermiso;
        }
        public String getDescripcionPermiso() 
        {
                return descripcionPermiso;
        }
        public void setDescripcionPermiso(String descripcionPermiso) 
        {
                this.descripcionPermiso = descripcionPermiso;
        }
}

Repositorio en Github

En el siguiente repositorio Github se puede encontrar un ejemplo totalmente funcional:

https://github.com/daxcurson/permisos-ejemplo/