1. Reglas de URL: un camino sin significado
Si lo simplificamos con una analogía cotidiana, la seguridad a nivel de solicitud es un guardia en la entrada del edificio que mira el pase y decide “dejar pasar / no dejar pasar”. Es útil, pero el guardia no sabe por qué entras ni a qué caja fuerte te diriges. En el mundo del backend, el “caja fuerte” es el método del servicio: ahí ocurre la “acción peligrosa” — publicación de contenido, bloqueo de usuario, cambio de roles. Y si solo protegemos la entrada por URL, protegemos la ruta, pero no la “caja fuerte” en sí.
En términos de Spring Security, la autorización a nivel de solicitud se basa en HttpServletRequest: la ruta, el método HTTP, a veces cabeceras, a veces el origen y otros parámetros de la solicitud. Pero la regla a nivel de solicitud no sabe que dentro del controlador estás llamando a reviewService.publish(draftId), y mucho menos sabe que el mismo publish(...) podría ser llamado desde otro controlador, otro endpoint o incluso no a través de HTTP (por ejemplo, desde otro servicio). Por lo tanto, una “URL cerrada” no significa una “operación de negocio cerrada”.
Para ser lo más concretos posible, tomemos una configuración conocida donde la matriz de acceso se expresa solo mediante zonas de URL:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Endpoints GET públicos: accesibles para todos, incluidos usuarios anónimos
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// Zona de editores: requiere un permiso específico (authority)
.requestMatchers("/api/editor/**").hasAuthority("draft:review")
// Zona de administración: permiso separado para gestionar usuarios
.requestMatchers("/api/admin/**").hasAuthority("user:manage")
// Todo lo demás: solo requiere autenticación (cualquiera, pero no anónimo)
.anyRequest().authenticated()
);
// Construimos el SecurityFilterChain que se aplicará a todas las solicitudes HTTP
return http.build();
}
}
A nivel del “perímetro” todo parece ordenado: lectura pública para todos, zona de editores para quienes saben revisar, zona de administración para quienes saben gestionar usuarios. Pero es importante ver el límite de este fragmento: sigue siendo una instantánea puramente a nivel de solicitud, donde los permisos solo viven en las entradas. Separa bien las zonas por URL, pero el propio publish(...), lockUser(...) y otras acciones peligrosas aún no tienen su propio candado.
Es aquí donde rápidamente surge la diferencia que la zona de URL suaviza: draft:review y draft:publish no son lo mismo, al igual que user:read y user:manage. A nivel de zona parecen demasiado similares, pero a nivel de operación de negocio ya no lo son.
2. Cuando la protección de URL no es suficiente
La primera vez que se nota esto no suele ser en una lección, sino en producción (lo que, francamente, es un laboratorio demasiado caro). Añades un nuevo endpoint, refactorizas algo, mueves código entre controladores — y de repente resulta que la restricción de la “operación peligrosa” estaba atada a una URL concreta, no a la operación en sí. Y sin mala intención aparece un “agujero”: no porque seas una mala persona, sino porque el cerebro no puede mantener en memoria todas las posibles entradas al sistema. Analicemos tres escenarios muy reales.
Imaginemos nuestro servicio que realmente realiza una acción peligrosa:
import org.springframework.stereotype.Service;
@Service
class ReviewService {
public void publish(Long draftId) {
// IMPORTANTE: esto es una operación de negocio (no un "trozo de controlador"),
// puede ser llamada desde diferentes lugares.
// TODO: cambiar estado DRAFT/SUBMITTED -> PUBLISHED
// TODO: establecer publishedAt
// TODO: registrar evento de publicación (para auditoría), si el dominio lo requiere.
}
}
Escenario 1: un mismo método de servicio es llamado desde dos controladores diferentes
Primero tenemos un endpoint “correcto”, por ejemplo de la zona de editores:
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class EditorController {
private final ReviewService reviewService;
EditorController(ReviewService reviewService) {
this.reviewService = reviewService;
}
@PostMapping("/api/editor/drafts/{id}/publish")
void publish(@PathVariable Long id) {
// Controlador delgado: recibe la solicitud HTTP y delega la operación de negocio al servicio
reviewService.publish(id);
}
}
Y parece que todo está bien: /api/editor/** está protegido, por lo tanto publish está protegido. Pero luego (en el futuro, o simplemente “un viernes por la noche”, cuando quieres irte a casa) alguien añade un endpoint para la zona de administración, porque “bueno, el admin puede hacerlo todo”:
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class AdminDraftController {
private final ReviewService reviewService;
AdminDraftController(ReviewService reviewService) {
this.reviewService = reviewService;
}
@PostMapping("/api/admin/drafts/{id}/publish")
void publishAsAdmin(@PathVariable Long id) {
// ATENCIÓN: seguimos llamando a la misma operación de negocio publish(...),
// pero la regla de URL sobre /api/admin/** puede ser completamente “sobre otra cosa”
// (por ejemplo, solo sobre user:manage)
reviewService.publish(id);
}
}
Si la regla a nivel de solicitud para /api/admin/** está escrita sobre gestión de usuarios (user:manage), entonces desde el punto de vista del “perímetro” no estás obligado a pensar en la publicación de borradores. Pero la operación de negocio ya se ha abierto a través de una nueva entrada. Y te ves obligado a ampliar las reglas de URL (y mantenerlas en la cabeza) o esperar que nadie más añada una tercera puerta a este mismo método.
Escenario 2: un patrón de URL describe una zona demasiado amplia
Una historia muy común es “hacerlo de una sola vez”:
.requestMatchers("/api/editor/**").authenticated()
Parece cómodo, hasta que aparece algo en la zona de editores que debería ser accesible solo para una parte de los usuarios (por ejemplo, la cola de revisión la ven todos los editores, pero publicar/rechazar solo algunos). A nivel de URL empiezas a dividir zonas y multiplicar matchers, o resignarte a que “bueno, si entró, entonces puede”.
Al principio es normal — no estamos construyendo un ACL empresarial. Pero es importante entender que cuanto más amplio es el matcher, menos se parece a una regla de negocio real. No es “malo”, es simplemente otro propósito: las reglas a nivel de solicitud son una separación gruesa de zonas, no un modelo preciso de acceso de negocio.
Escenario 3: la operación de negocio se llama desde fuera del controlador
Este es el momento en que un desarrollador junior suele decir: “Espera… ¿se podía hacer eso?”
Sí, se puede. Dentro de la aplicación, el método reviewService.publish(...) puede ser llamado:
— desde otro servicio (por ejemplo, si haces un flujo de orquestación);
— desde un manejador de eventos;
— desde un componente utilitario “temporal”;
— en tests (lo que es especialmente divertido: el test pasa, pero en producción falla por acceso, porque la seguridad solo estaba en la capa HTTP).
La seguridad a nivel de solicitud solo protege el camino que comienza con una petición HTTP. Todo lo que ocurre “dentro” ya son simples llamadas Java.
Podemos representarlo con un esquema simple. Está deliberadamente sin detalles internos de Spring, para que se vea lo principal:
flowchart TD
A["Solicitud HTTP"] --> B["SecurityFilterChain reglas a nivel de solicitud"]
B --> C["Controlador"]
C --> D["Método de servicio: publish/lockUser/changeRoles"]
E["Otro Controlador"] --> D
F["Otro Servicio"] --> D
G["Cualquier componente Spring"] --> D
Si la protección vive solo en el punto B, entonces las ramas E/F/G (cualquier entrada no HTTP u “otras entradas HTTP”) quedan sin una vinculación directa a la regla que “mentalmente” asociaste con publish. Y ese es justo el momento en que el proyecto empieza a sentirse frágil: parece que todo está “según las reglas”, pero por alguna razón da miedo refactorizar.
3. Duplicación de reglas y copia y pega
Uno de los errores más costosos en el desarrollo backend no es “escribir incorrectamente”, sino “escribir lo mismo en dos sitios, pero con el tiempo se vuelve diferente”. La seguridad encaja perfectamente aquí: puedes proteger honestamente un endpoint en un controlador, luego añadir un segundo endpoint, luego un tercero — y en algún momento las reglas empiezan a divergir. No porque seas perezoso, sino porque el cerebro no es un sistema de control de versiones.
Casi siempre la historia comienza de forma inocente: “vamos a extraer la misma operación a un servicio para no duplicar el código de negocio”. Y eso está bien. Pero si la seguridad se queda solo en las reglas de URL, se produce una simetría extraña:
— el código de negocio lo centralizamos en el servicio;
— el código de seguridad no lo centralizamos y queda esparcido por las entradas.
En la práctica te encuentras con estos efectos: un endpoint devuelve 403, otro devuelve 200, pero ambos llaman al mismo método. O al revés: añadiste un “nuevo endpoint correcto” y te olvidaste de cerrar el antiguo — y sigue funcionando. Es especialmente “divertido” cuando el antiguo endpoint no lo usa el frontend y nadie lo prueba manualmente. Simplemente existe en silencio y espera su momento de gloria.
Para sentir la diferencia, es útil comparar dos “imágenes del mundo” — a nivel de solicitud y a nivel de servicio. Lo haré en una tabla, porque a veces el cerebro humano necesita un rectángulo y no un flujo de conciencia.
| Qué protegemos | Dónde está escrita la regla | En qué puede basarse la regla | Cuántas “entradas” cubre la regla |
|---|---|---|---|
| Entrada HTTP (URL, método) | SecurityFilterChain | path, HttpMethod, cabeceras | Solo las entradas que coinciden en SecurityFilterChain |
| Operación de negocio (publish/lockUser) | cerca del método de servicio | autenticación actual y permisos | Cualquier llamada al método a través de la infraestructura Spring (detalles más adelante hoy) |
La segunda fila es ese “cambio de mentalidad” por el que llegamos a la seguridad a nivel de método. No para “reemplazar las reglas de URL”, sino para añadir un segundo nivel que protege no la ruta, sino la acción.
4. El controlador no es el lugar para la autorización
En este punto suele surgir un pensamiento natural: “Vale, las reglas de URL no son ideales. Entonces haré la comprobación dentro del controlador: if (user not editor) throw — y listo.”
Parece una solución, pero rápidamente se convierte en un problema. El controlador es la capa de HTTP: parámetros de solicitud, códigos de estado, DTOs, serialización, algo de validación. En cuanto empiezas a escribir autorización completa en el controlador, este se convierte en un “centro de toma de decisiones”, y el servicio se transforma en un “ejecutor tonto”. Exactamente lo contrario de lo que queremos.
Aquí hay un ejemplo de “autorización en el controlador” que parece normal, pero en la práctica se extiende rápidamente:
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class EditorController {
private final ReviewService reviewService;
EditorController(ReviewService reviewService) {
this.reviewService = reviewService;
}
@PostMapping("/api/editor/drafts/{id}/publish")
void publish(@PathVariable Long id, Authentication auth) {
// Mala idea: el controlador empieza a "decidir" quién puede hacer qué,
// y esta lógica se extiende a otras entradas (otros controladores/endpoints)
if (auth == null || auth.getAuthorities().isEmpty()) {
// Mala idea para una API: una excepción sobre "estado de la aplicación" se convierte en 500,
// aunque por sentido debería ser 401/403 (según la situación)
throw new IllegalStateException("No authorities");
}
// Aunque la comprobación se haga "elegantemente", igual duplicas reglas por entrada
reviewService.publish(id);
}
}
El problema no es que IllegalStateException “no sea elegante” (aunque también eso). El problema es que acabas de mezclar responsabilidades. El controlador ahora no solo es responsable de la entrada al sistema, sino también del modelo de acceso. Y si aparece una segunda entrada a reviewService.publish(...) — tendrás que copiar esta lógica allí también. Y luego otra vez. Y otra más. Y así ya estás construyendo tu propio mini‑Spring Security sobre la marcha, aunque en el proyecto ya tenemos Spring Security.
Además, la lógica en el controlador casi siempre lleva a una mala semántica de errores. Todo el bloque anterior trataba de distinguir 401 y 403, y de que los errores se vieran uniformes. Los if manuales convierten muy fácilmente un 403 en un 500, o al revés, y el cliente recibe sorpresas.
Por eso, la idea del día suena un poco aburrida, pero ahorra nervios: el controlador debe ser delgado, el servicio inteligente, y las reglas de acceso deben vivir cerca de lo que protegen. Si protegemos “publish”, entonces la regla no debe estar “alrededor de la URL”, sino “alrededor de publish”.
5. Ejemplos de riesgo: publish y operaciones de administración
Es importante no solo filosofar, sino ver lugares concretos del proyecto donde la protección por URL casi inevitablemente empieza a “no coincidir” con las operaciones de negocio. En nuestra Secure Content Platform API hay dos familias típicas de acciones: moderación de contenido por parte de editores y operaciones administrativas sobre cuentas. Son buenos ejemplos porque tienen un alto coste de error, y por eso es más fácil percibirlas como operaciones “peligrosas” que deben protegerse de la forma más obvia posible.
Tomemos dos servicios muy similares en cuanto a propósito: uno hace moderación de contenido, el otro gestiona usuarios.
import org.springframework.stereotype.Service;
@Service
class ReviewService {
public void publish(Long draftId) {
// Operación peligrosa: publicar contenido (cambia el estado del sistema)
// Aquí es especialmente importante que la regla de acceso esté vinculada al método, no solo a la URL.
}
}
import org.springframework.stereotype.Service;
@Service
class AdminUserService {
public void lockUser(Long userId) {
// Operación peligrosa: bloquear cuenta de usuario
// Esto también es una "capacidad" del sistema, y para ella se desea una regla explícita junto al método.
}
}
A nivel de reglas de URL, estas operaciones suelen vivir en diferentes zonas:
— publish/reject: /api/editor/**
— lock/enable/change roles: /api/admin/**
Y parece que todo está correcto. Pero aquí aparece el matiz.
Matiz n.º 1: zona ≠ operación. /api/admin/** en nuestro proyecto trata sobre usuarios. Si accidentalmente añades allí una operación de contenido (como en el ejemplo de AdminDraftController), entonces la regla sobre /api/admin/** no refleja en absoluto el significado de publish. Refleja el significado de “gestión de usuarios”. Son permisos diferentes (user:manage vs draft:publish). Al final, o abres publish a quienes no deberían tenerlo, o al revés — “rompes” publish para quienes lo necesitan.
Matiz n.º 2: diferentes endpoints pueden llevar a la misma acción. Puedes tener un endpoint que publica el borrador, otro que “publica y notifica inmediatamente a los suscriptores” (digamos), un tercero que “publica desde la cola”. Todos pueden llamar al mismo método de servicio. Y lo que quieres es tener una única regla: “puede publicar quien tenga permiso publish”. Cuanto antes empieces a pensar así, menos miedo tendrás a añadir nuevos endpoints.
Matiz n.º 3: los métodos de servicio son capacidades reales de negocio. Si en el código existe un método lockUser(...), entonces eso ya es una capacidad del sistema: “sabemos bloquear cuentas”. Y es muy deseable que junto a ese método haya una reglo lo más obvia posible de “quién tiene derecho a hacer esto”. No en el README, no en la cabeza, no en el controlador, no en SecurityConfig en algún lugar al fondo, sino junto a la propia acción.
Aquí ya se ve qué regla necesitamos: el acceso debe colgar no de una nueva puerta en SecurityConfig, sino directamente de la propia operación publish, lockUser o changeRoles. Para eso aparece una capa separada — la seguridad a nivel de método, que sabe poner un candado en el método del servicio, no solo en la URL.
6. Dos niveles de protección: URL y métodos
Es muy importante no sacar una conclusión incorrecta. Las reglas de URL no se vuelven “malas” ni requieren eliminación solo porque tenemos un segundo nivel de protección. Al contrario: siguen siendo la primera línea de defensa, y son ellas las que rápidamente descartan solicitudes anónimas y delimitan las zonas de la aplicación. Si eliminas las reglas a nivel de solicitud y dejas solo la seguridad a nivel de método, obtendrás una aplicación extraña y menos predecible: los controladores se invocarán más a menudo, el manejo de errores puede volverse menos obvio y la configuración perderá el “mapa de entradas”.
La imagen correcta es esta: las reglas a nivel de solicitud se encargan de que la solicitud llegue a la zona adecuada y no pase por “una puerta abierta”. Las reglas a nivel de servicio se encargan de que incluso dentro de la zona no se pueda hacer algo para lo que no se tiene permiso. Esto se llama “defense in depth”, pero sin pompa: simplemente dos candados en lugar de uno, porque a veces la gente se olvida de cerrar uno.
Si mantienes esto en mente, aparece un hábito de diseño útil. Cuando añades un nuevo endpoint, te haces dos preguntas. Primero: “¿en qué zona cae y qué dirá SecurityFilterChain?”; luego: “¿qué operación de negocio invoca y tiene esa operación protección local?”. Y la segunda pregunta es justo lo que nos faltaba hasta ahora.
7. Errores típicos al proteger operaciones
Error n.º 1: pensar que “cerrar /api/editor/** significa que publish está protegido para siempre”.
Esta forma de pensar funciona solo mientras estés seguro de que publish se llama desde exactamente un controlador y nunca aparecerá una segunda entrada. En un proyecto real, surgen constantemente nuevas entradas: nuevos endpoints, nuevos escenarios, manijas administrativas temporales, refactorización. Si la protección vive solo en SecurityFilterChain, empiezas a proteger rutas, no acciones — y la seguridad se vuelve frágil.
Error n.º 2: trasladar la autorización al controlador mediante ifs porque “es más rápido”.
La autorización en el controlador casi siempre lleva a esparcir reglas por todo el proyecto, a duplicación y a una mala semántica de errores. Además, dificulta el mantenimiento: cambias una regla de negocio y luego buscas por todo el código en qué controlador estaba “incrustada”. Como resultado, los controladores engordan, los servicios adelgazan y el proyecto se convierte en un laberinto donde el derecho de acceso depende del camino por el que entres.
Error n.º 3: proteger zonas con matchers demasiado amplios e intentar expresar reglas de negocio precisas solo mediante URL.
La tentación de “cerrar todo con una línea” es comprensible, especialmente cuando quieres obtener rápidamente una línea base de seguridad funcional. Pero cuanto más amplia es la regla de URL, menos se parece a un derecho de negocio. En algún momento empiezas a dividir matchers infinitamente o a resignarte a que “en la zona se puede todo”. Ambas opciones escalan mal a medida que el proyecto crece y aparecen authorities.
Error n.º 4: confundir “lo que es cómodo de configurar” con “lo que es correcto proteger”.
La configuración de URL es realmente cómoda: se ve en un solo lugar y se lee de arriba abajo. Pero las operaciones de negocio no viven en la configuración, viven en los servicios. Si una operación es peligrosa e importante (publish, bloquear usuario, cambiar roles), el deseo de ver junto a ella una regla de acceso explícita no es un capricho, sino un intento de hacer el sistema comprensible y resistente a cambios. Cuando la protección está colocada junto a la acción, el proyecto se parece menos a un castillo de naipes y más a un backend normal que se puede desarrollar sin miedo.
GO TO FULL VERSION