1. authenticated() — solo la primera barrera
Los roles, authorities y @PreAuthorize ya han respondido a la pregunta “¿a qué tipo de usuario se le permite realizar la acción?”. Pero para /api/me y los borradores esto no es suficiente. Aquí el sistema no solo debe permitir que un usuario entre en la zona privada, sino que debe asociar la solicitud específica con el propietario del recurso. Por lo tanto, es útil tener una línea base funcional para cuatro escenarios: GET /api/me, PATCH /api/me/profile, GET /api/drafts y GET /api/drafts/{id}.
Para ser sinceros, authenticated() es como el candado de la entrada del edificio. Es útil, pero no responde a la pregunta “¿en qué apartamento estás?”. Para el acceso basado en propietario, esta es precisamente la primera barrera: no deja pasar a los anónimos, pero no resuelve “este draft es tuyo o ajeno”.
También es importante que este esquema sobrevivirá a la transición a un modelo sin estado. Cambiará la forma en que Authentication llega al SecurityContext, pero el usuario actual, la verificación del propietario y la distinción entre 401 y 403 seguirán siendo los mismos.
2. Esqueleto de la ruta: SecurityFilterChain → controller → service → DB
Para no dispersar las comprobaciones en lugares aleatorios, es conveniente mantener en mente una única ruta de solicitud:
flowchart TD
A["HTTP request"] --> B["SecurityFilterChain
request-level rules"]
B -->|"401 si anónimo"| X["AuthenticationEntryPoint"]
B --> C["Controller
@AuthenticationPrincipal"]
C --> D["Service
business action"]
D --> E["Repository/DB
load target object"]
E --> F{"comprobación de propietario:
authorId == currentUserId?"}
F -->|"no"| Y["AccessDeniedException -> 403
AccessDeniedHandler + JSON error"]
F -->|"sí"| G["Business logic + response"]
La idea es simple: SecurityFilterChain filtra a los anónimos y delimita las zonas de acceso, el controlador obtiene un usuario actual de confianza, el servicio toma la decisión de negocio, y la base de datos proporciona el ownerId del objeto concreto. El controlador no se convierte aquí en un mini servicio de seguridad. Simplemente pasa al servicio «quién ha llegado» en forma de identificador de confianza.
A nivel de authorizeHttpRequests, aquí es suficiente el DSL habitual: authenticated(), hasAnyRole(), hasRole(), denyAll(). Con esto separamos las zonas de acceso. El acceso basado en expresiones mediante access(...) y SpEL es una herramienta aparte; para esta línea base no ocultamos la verificación de propietario en él, sino que la dejamos en el servicio, junto con la acción de negocio.
Ejemplo de configuración «nivel 20» (sin exotismos innecesarios, simplemente un mapa legible de zonas):
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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
// Self y drafts: solo permitimos a usuarios autenticados (es la "primera barrera")
.requestMatchers("/api/me/**", "/api/drafts/**").authenticated()
// Zona de editores: solo roles EDITOR o ADMIN
.requestMatchers("/api/editor/**").hasAnyRole("EDITOR", "ADMIN")
// Zona de administración: solo ADMIN
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Todo lo que no esté explícitamente descrito, se deniega
.anyRequest().denyAll()
);
return http.build();
}
}
Estas reglas delimitan las zonas de acceso, pero no convierten automáticamente ningún draft dentro de /api/drafts/** en «propio». La decisión de owner-only sigue viviendo junto a la acción de negocio concreta.
3. Self-endpoints: /api/me/** sin userId
Los self-endpoints son una pequeña alegría ingenieril. Permiten hacer la API a la vez más segura y más simple. En estos endpoints decimos explícitamente: «esta acción es en nombre del usuario actual». Por lo tanto, no aceptamos userId en la ruta ni en el cuerpo, porque eso crea inmediatamente la tentación: «¿y si pongo otro id?». El cliente no necesita decirle al servidor quién es. El servidor ya lo sabe.
Empecemos con un /api/me simple. Tomamos el principal del contexto de seguridad y devolvemos al cliente la información mínima: id y username. Importante: no vamos a la base de datos «por tradición» si el principal ya contiene userId. Si en tu implementación el principal solo almacena el username, también está bien, pero entonces puedes buscar al usuario adicionalmente por username.
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeController {
// Self-endpoint: trabajamos solo con el usuario actual, sin userId del cliente
@GetMapping("/api/me")
MeResponse me(@AuthenticationPrincipal AppUserPrincipal principal) {
// Obtenemos el identificador de confianza del principal, no de la solicitud
return new MeResponse(principal.getUserId(), principal.getUsername());
}
}
El DTO puede ser lo más modesto posible. Cuanto menos «saques» hacia fuera, menos tendrás que explicar después por qué has expuesto accidentalmente un campo innecesario.
// Respuesta mínima: solo lo que realmente necesita el cliente
public record MeResponse(Long id, String username) {
}
Ahora /api/me/profile. La lógica es similar, pero especialmente importante es que la actualización del perfil no acepte userId. Si el cliente envía «quiero actualizar el perfil del usuario 777», el servidor debería responder «muy interesante, pero yo quiero vacaciones».
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeProfileController {
private final ProfileService profileService;
MeProfileController(ProfileService profileService) {
this.profileService = profileService;
}
// Self-endpoint: actualizamos el perfil estrictamente del usuario actual
@PatchMapping("/api/me/profile")
void update(@AuthenticationPrincipal AppUserPrincipal principal,
@RequestBody UpdateProfileRequest request) {
// currentUserId se toma solo del contexto de seguridad (principal)
profileService.updateOwnProfile(principal.getUserId(), request);
}
}
Y la solicitud de actualización de perfil que sea simplemente «qué cambiar», sin identificadores:
public record UpdateProfileRequest(String displayName, String bio) {
}
Aquí el owner está prácticamente integrado en la forma de la API: el servicio no averigua «si el userId de la ruta coincide con currentUserId», porque la ruta no tiene userId en absoluto. Simplemente recibe un currentUserId de confianza y realiza la operación de negocio habitual.
4. Borradores: /api/drafts y /api/drafts/{id}
En /api/me/** el cliente no tiene dónde poner un id ajeno. En los borradores, al contrario, el id del recurso viene del exterior. Por lo tanto, aquí el usuario actual del principal debe encontrarse con el ownerId del recurso de la base de datos.
En el controlador, el cliente sigue pasando solo draftId. currentUserId lo tomamos del principal:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DraftController {
private final DraftQueryService draftQueryService;
DraftController(DraftQueryService draftQueryService) {
this.draftQueryService = draftQueryService;
}
// Importante: el cliente pasa solo el id del draft, currentUserId se toma del principal
@GetMapping("/api/drafts/{id}")
ContentItem get(@PathVariable Long id,
@AuthenticationPrincipal AppUserPrincipal principal) {
return draftQueryService.getOwnDraft(id, principal.getUserId());
}
}
Para la lista, la lógica es aún más simple: no cargamos «todos los drafts del sistema» para luego filtrarlos en memoria. Hacemos directamente una consulta en la base de datos limitada al propietario.
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
interface ContentItemRepository extends JpaRepository<ContentItem, Long> {
// Importante: el filtrado por authorId ocurre a nivel de base de datos, no "después de cargar todos los datos"
List<ContentItem> findAllByAuthorId(Long authorId);
}
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
class DraftService {
private final ContentItemRepository repository;
DraftService(ContentItemRepository repository) {
this.repository = repository;
}
// Method security: incluso la lista de drafts "propios" requiere un permiso específico
@PreAuthorize("hasAuthority('draft:read:own')")
List<ContentItem> listOwnDrafts(Long currentUserId) {
// currentUserId debe venir de una fuente de confianza (principal), no de los parámetros de la solicitud del cliente
return repository.findAllByAuthorId(currentUserId);
}
}
Y GET /api/drafts/{id} ya requiere una comprobación explícita de propietario: el usuario actual viene de la capa de seguridad, el ownerId del propio objeto.
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class DraftQueryService {
private final ContentItemRepository repository;
DraftQueryService(ContentItemRepository repository) {
this.repository = repository;
}
// Permisos para leer los drafts "propios"
@PreAuthorize("hasAuthority('draft:read:own')")
ContentItem getOwnDraft(Long draftId, Long currentUserId) {
// 1) Buscamos el objeto por id (esto todavía no es autorización)
ContentItem draft = repository.findById(draftId).orElseThrow();
// 2) Autorización por propietario: si es ajeno, devolvemos 403 mediante AccessDeniedException
if (!draft.getAuthorId().equals(currentUserId)) {
throw new AccessDeniedException("foreign_draft");
}
return draft;
}
}
Si draftId no existe, entra en juego la ruta habitual de not-found del proyecto. Si el objeto se encuentra pero el propietario no coincide, eso ya es un 403.
Si un editor necesita acceso a drafts ajenos para revisión, eso es un endpoint separado y una authority independiente como draft:review. El método owner-only no debería convertirse de repente en un combinado owner || editor || admin.
5. Resultado final: 401, 403, 404 y rastro en los logs
Ahora la ruta se puede leer hasta el final. Si la solicitud viene de un anónimo, la detiene SecurityFilterChain, y AuthenticationEntryPoint devuelve 401. Si draftId no existe, entra en juego la ruta habitual de not-found del proyecto: 404. Si el objeto existe pero la comprobación de propietario en el servicio falla, AccessDeniedException se convierte en 403 a través de AccessDeniedHandler.
Esta es la línea base canónica de los escenarios owner-only: no mezclar «no encontrado» y «ajeno».
Junto a la comprobación de propietario, es útil dejar un breve rastro: actor, acción, objetivo. El 403 general se puede registrar adicionalmente a nivel de frontera, pero la violación exacta del propietario se ve mejor justo al lado de la decisión de acceso.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
class DraftOwnership {
private static final Logger log = LoggerFactory.getLogger(DraftOwnership.class);
static void requireOwner(Long draftAuthorId, Long currentUserId, Long draftId) {
// Punto central de decisión: "propio/ajeno"
if (!draftAuthorId.equals(currentUserId)) {
// Solo registramos actor/acción/objetivo — sin tokens ni cabeceras
log.warn("access_denied action=read_draft actorUserId={} draftId={}",
currentUserId, draftId);
// Esto se convierte en 403 a través de AccessDeniedHandler
throw new AccessDeniedException("foreign_draft");
}
}
}
Esto es suficiente para luego encontrar intentos de lectura de drafts ajenos sin imprimir tokens, cookies ni cabeceras.
6. Errores típicos
Error nº1: el self-endpoint acepta userId del cliente «por comodidad».
Esto suele empezar con la frase «bueno, por si al frontend le resulta más fácil». Luego alguien introduce otro userId y de repente has inventado una API para ver perfiles ajenos. Los self-endpoints son buenos precisamente porque no requieren confiar en identificadores de entrada: el usuario actual se toma del contexto de seguridad, y punto.
Error nº2: authenticated() en /api/drafts/** se percibe como una protección completa.
authenticated() realmente protege contra anónimos, pero no protege en absoluto contra «un id ajeno». Si dentro de la zona no hay comprobación de propietario, cualquier usuario autenticado podrá leer, actualizar y eliminar objetos ajenos simplemente iterando identificadores.
Error nº3: la comprobación de propietario se hace en el controlador y el servicio permanece «ciego».
Al principio parece lógico: «en el controlador está @PathVariable id, pues ahí lo comprobamos». Pero en cuanto la operación de negocio empieza a ser llamada no solo desde un controlador (o añades un segundo endpoint), las comprobaciones se dispersan. La capa de servicio es el lugar donde la regla «propio/ajeno» vive de forma estable y uniforme.
Error nº4: AccessDeniedException se reemplaza por «devolvamos 404 para no revelar que el objeto existe».
A veces esto es una estrategia consciente, pero para nuestra línea base distinguimos entre «no encontrado» y «encontrado pero no permitido». Si el draft existe pero es ajeno, es 403. Si el draft no existe, es 404. Mezclar estos significados empeora el mantenimiento y el diagnóstico, además de romper el mapa claro de 401/403.
Error nº5: en el log se escribe información innecesaria, incluidos tokens, cookies y cabeceras.
Una denegación basada en propietario es una excelente ocasión para un log de warn con actor/acción/objetivo. Pero no es una excusa para registrar «todo lo que llegó en la solicitud». Es especialmente malo registrar valores que luego puedan reutilizarse (session id, token CSRF, cabecera Authorization). El log debe ayudarte a ti, no al atacante.
GO TO FULL VERSION