AuditoriaAspect.java

package com.licensis.notaire.audit;

import com.licensis.notaire.negocio.RegistroAuditoria;
import com.licensis.notaire.negocio.Usuario;
import com.licensis.notaire.repository.UsuarioRepository;
import com.licensis.notaire.service.RegistroAuditoriaService;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;

import java.lang.reflect.Method;
import java.util.Date;
import java.util.Locale;
import java.util.Optional;

/**
 * Spring AOP aspect that writes a business audit record to the
 * {@code registro_auditoria} table for every REST operation handled by
 * controllers in the {@code com.licensis.notaire.api} package.
 *
 * <p>The aspect is intentionally tolerant: any failure during the audit
 * write is logged at WARN level and swallowed, so functional flows are
 * never broken by audit-related issues.
 */
@Aspect
@Component
public class AuditoriaAspect {

    private static final Logger log = LoggerFactory.getLogger(AuditoriaAspect.class);

    /**
     * Legacy header the Next.js frontend still sends with the logged-in
     * user's name. No longer trusted for audit attribution (issue #555):
     * a client-supplied header is trivially forgeable. Kept here only as
     * the well-known header name for tests asserting it is ignored.
     */
    public static final String ACTING_USER_HEADER = "X-Notaire-User";

    private final RegistroAuditoriaService auditoriaService;
    private final UsuarioRepository usuarioRepository;

    public AuditoriaAspect(RegistroAuditoriaService auditoriaService, UsuarioRepository usuarioRepository) {
        this.auditoriaService = auditoriaService;
        this.usuarioRepository = usuarioRepository;
    }

    /**
     * Pointcut that targets any public method declared in any class of the
     * {@code com.licensis.notaire.api} package. The
     * {@link RegistroAuditoriaController} itself is excluded to avoid
     * recursive auditing of audit reads.
     */
    @Pointcut(
        "execution(public * com.licensis.notaire.api..*Controller.*(..)) "
        + "&& !within(com.licensis.notaire.api.RegistroAuditoriaController)"
    )
    public void controllerMethods() {
        // Pointcut signature only.
    }

    @AfterReturning("controllerMethods()")
    public void auditAfterControllerInvocation(JoinPoint joinPoint) {
        try {
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();

            // Only business operations are audited: create / update / delete and
            // authentication events. Read-only queries (GET) are intentionally
            // skipped so the audit trail stays focused and is not flooded by the
            // many list refreshes the UI performs.
            if (!isAuditableOperation(method)) {
                return;
            }

            String controllerName = resolveControllerSimpleName(joinPoint);
            String modulo = AuditModuleResolver.resolve(controllerName);
            String detalle = AuditOperationDescriber.describe(method, joinPoint.getArgs(), modulo);

            Usuario usuario = resolveCurrentUser().orElse(null);
            if (usuario == null) {
                log.debug("Skipping audit for {} — no acting user", controllerName);
                return;
            }

            RegistroAuditoria registro = new RegistroAuditoria();
            registro.setFecha(new Date());
            registro.setModulo(modulo);
            registro.setDetalleOperacion(detalle);
            registro.setFkIdUsuario(usuario);

            auditoriaService.save(registro);
            log.debug("Audit record created — modulo={}, detalle={}, usuario={}",
                    modulo, detalle, usuario.getNombre());
        } catch (RuntimeException ex) {
            // Never break functional flows because of audit failures.
            log.warn("Could not write audit record: {}", ex.getMessage());
        }
    }

    /**
     * Resolves the original controller simple name. When the target is a
     * Spring CGLIB or Mockito proxy ({@code EscrituraController$$EnhancerBy...}
     * / {@code EscrituraController$MockitoMock$...}), we walk up to the
     * declaring superclass to recover the original name.
     */
    private String resolveControllerSimpleName(JoinPoint joinPoint) {
        Class<?> targetClass = joinPoint.getTarget().getClass();
        String simpleName = targetClass.getSimpleName();
        if (simpleName.contains("$$") || simpleName.contains("$Mockito")
                || simpleName.contains("EnhancerBy") || simpleName.contains("MockitoMock")) {
            Class<?> parent = targetClass.getSuperclass();
            if (parent != null && parent != Object.class) {
                simpleName = parent.getSimpleName();
            }
        }
        // Also fall back to the signature's declaring class if available.
        if (simpleName == null || simpleName.isBlank()) {
            simpleName = joinPoint.getSignature().getDeclaringType().getSimpleName();
        }
        return simpleName;
    }

    /**
     * Only audit state-changing business operations (create / update / delete)
     * and authentication events (login / logout). Read-only GET endpoints are
     * not auditable.
     */
    private boolean isAuditableOperation(Method method) {
        if (method == null) {
            return false;
        }
        String name = method.getName().toLowerCase(Locale.ROOT);
        if (name.contains("login") || name.contains("logout")) {
            return true;
        }
        return method.isAnnotationPresent(PostMapping.class)
                || method.isAnnotationPresent(PutMapping.class)
                || method.isAnnotationPresent(PatchMapping.class)
                || method.isAnnotationPresent(DeleteMapping.class);
    }

    /**
     * Resolves the acting user solely from the verified Spring Security
     * context populated by {@link com.licensis.notaire.config.JwtAuthenticationFilter}
     * for a valid Bearer token. No client-supplied header is trusted (issue #555):
     * every state-changing {@code /api/**} request requires authentication, so by
     * the time this aspect runs, an authenticated request always has one.
     */
    private Optional<Usuario> resolveCurrentUser() {
        String name = resolveActingUsername();
        if (name == null || name.isBlank()) {
            return Optional.empty();
        }
        return usuarioRepository.findAll().stream()
                .filter(u -> u.getNombre() != null && u.getNombre().equalsIgnoreCase(name))
                .findFirst();
    }

    private String resolveActingUsername() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.isAuthenticated()) {
            String name = authentication.getName();
            if (name != null && !name.isBlank() && !"anonymousUser".equals(name)) {
                return name;
            }
        }
        return null;
    }
}