Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions api/src/main/java/synapse/api/auditoria/AcessoAuditoria.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package synapse.api.auditoria;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Component;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import synapse.api.core.logging.CorrelationContext;
import synapse.api.core.security.AcessoDoUsuario;
import synapse.api.core.security.PapelDoUsuario;
import synapse.api.core.security.UsuarioAtual;

@Configuration
class AcessoAuditoriaConfig implements WebMvcConfigurer {

private final AcessoAuditoriaInterceptor interceptor;

AcessoAuditoriaConfig(AcessoAuditoriaInterceptor interceptor) {
this.interceptor = interceptor;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(this.interceptor).addPathPatterns("/auditoria/**");
}

}

/**
* Política das rotas de auditoria: só o papel de auditor passa, sem conferir posse. Roda
* antes de interpretar os parâmetros e de ler qualquer dado, então uma recusa nunca chega
* ao controller. Caminho sem rota segue para o 404 do Spring.
*/
@Component
class AcessoAuditoriaInterceptor implements HandlerInterceptor {

static final String ACESSO = "acessoAutorizadoAAuditoria";

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

private final UsuarioAtual usuarioAtual;

private final CorrelationContext correlacao;

AcessoAuditoriaInterceptor(UsuarioAtual usuarioAtual, CorrelationContext correlacao) {
this.usuarioAtual = usuarioAtual;
this.correlacao = correlacao;
}

@Override
public boolean preHandle(HttpServletRequest requisicao, HttpServletResponse resposta, Object handler) {
if (CorsUtils.isPreFlightRequest(requisicao) || !(handler instanceof HandlerMethod)) {
return true;
}
AcessoDoUsuario acesso;
try {
acesso = this.usuarioAtual.obter();
}
catch (AccessDeniedException ex) {
// A mensagem do UsuarioAtual fala do token e da conta; fica fora do log.
log.atWarn().addKeyValue("motivo", "identidade_recusada").log("acesso à auditoria recusado");
throw new SemPermissaoNaAuditoriaException();
}
if (acesso.papel() != PapelDoUsuario.AUDITOR) {
try (var escopo = this.correlacao.abrir(null, acesso.usuarioId().toString())) {
log.atWarn().addKeyValue("motivo", "papel_sem_acesso").log("acesso à auditoria recusado");
}
throw new SemPermissaoNaAuditoriaException();
}
requisicao.setAttribute(ACESSO, acesso);
return true;
}

}
83 changes: 83 additions & 0 deletions api/src/main/java/synapse/api/auditoria/AuditoriaAdvice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package synapse.api.auditoria;

import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import synapse.api.core.logging.CorrelationContext;
import synapse.api.core.security.AcessoDoUsuario;

@RestControllerAdvice(basePackageClasses = AuditoriaAdvice.class)
@Order(Ordered.HIGHEST_PRECEDENCE)
class AuditoriaAdvice {

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

private final CorrelationContext correlacao;

AuditoriaAdvice(CorrelationContext correlacao) {
this.correlacao = correlacao;
}

@ExceptionHandler(SemPermissaoNaAuditoriaException.class)
ResponseEntity<ErroAuditoriaDto> semPermissao() {
return erro(HttpStatus.FORBIDDEN, "sem_permissao", "Você não tem permissão para esta ação.");
}

@ExceptionHandler(JobNaoEncontradoNaAuditoriaException.class)
ResponseEntity<ErroAuditoriaDto> jobNaoEncontrado() {
return erro(HttpStatus.NOT_FOUND, "job_nao_encontrado", "Job não encontrado.");
}

@ExceptionHandler(RequisicaoInvalidaNaAuditoriaException.class)
ResponseEntity<ErroAuditoriaDto> requisicaoInvalida(RequisicaoInvalidaNaAuditoriaException ex) {
return erro(HttpStatus.BAD_REQUEST, "requisicao_invalida", String.valueOf(ex.getMessage()));
}

/**
* O Spring recusa o tipo antes do controller, que então não registra o desfecho. O
* log leva só o nome do parâmetro, nunca o valor recebido.
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
ResponseEntity<ErroAuditoriaDto> parametroComTipoInvalido(MethodArgumentTypeMismatchException ex,
HttpServletRequest requisicao) {
boolean consulta = ex.getName().equals("id");
String usuario = requisicao.getAttribute(AcessoAuditoriaInterceptor.ACESSO) instanceof AcessoDoUsuario acesso
? acesso.usuarioId().toString() : null;
try (var escopo = this.correlacao.abrir(null, usuario)) {
log.atWarn()
.addKeyValue("operacao", consulta ? "consultar_job_auditoria" : "listar_jobs_auditoria")
.addKeyValue("resultado", "requisicao_invalida")
.addKeyValue("parametro", ex.getName())
.log(consulta ? "consulta de auditoria encerrada" : "listagem de auditoria encerrada");
}
return requisicaoInvalida(switch (ex.getName()) {
case "id" -> RequisicaoInvalidaNaAuditoriaException.idDoJob();
case "tamanho" -> RequisicaoInvalidaNaAuditoriaException.tamanho();
default -> RequisicaoInvalidaNaAuditoriaException.pagina();
});
}

/** O corpo vazio não expõe a mensagem do driver. */
@ExceptionHandler(DataAccessException.class)
ResponseEntity<Void> falhaDePersistencia() {
return ResponseEntity.internalServerError().build();
}

private static ResponseEntity<ErroAuditoriaDto> erro(HttpStatus status, String codigo, String mensagem) {
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(new ErroAuditoriaDto(codigo, mensagem));
}

}
99 changes: 99 additions & 0 deletions api/src/main/java/synapse/api/auditoria/AuditoriaController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package synapse.api.auditoria;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import synapse.api.core.logging.CorrelationContext;
import synapse.api.core.security.AcessoDoUsuario;

/**
* Leitura investigativa dos jobs de qualquer dono. O acesso já foi decidido por
* {@link AcessoAuditoriaInterceptor}. Os logs levam o desfecho e as referências, nunca o
* conteúdo devolvido.
*/
@RestController
class AuditoriaController {

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

private final AuditoriaService service;

private final CorrelationContext correlacao;

AuditoriaController(AuditoriaService service, CorrelationContext correlacao) {
this.service = service;
this.correlacao = correlacao;
}

@GetMapping(path = "/auditoria/jobs", produces = "application/json")
PaginaJobsAuditoriaDto listar(@RequestParam(name = "pagina", defaultValue = "0") int pagina,
@RequestParam(name = "tamanho", defaultValue = "20") int tamanho,
@RequestAttribute(AcessoAuditoriaInterceptor.ACESSO) AcessoDoUsuario acesso) {
try (var escopo = this.correlacao.abrir(null, acesso.usuarioId().toString())) {
long inicio = System.nanoTime();
String resultado = "falha";
try {
PaginaJobsAuditoriaDto resposta = this.service.listar(new PaginaAuditoria(pagina, tamanho));
resultado = "sucesso";
return resposta;
}
catch (RequisicaoInvalidaNaAuditoriaException ex) {
resultado = "requisicao_invalida";
throw ex;
}
finally {
log.atLevel(nivel(resultado))
.addKeyValue("operacao", "listar_jobs_auditoria")
.addKeyValue("resultado", resultado)
.addKeyValue("pagina", pagina)
.addKeyValue("tamanho", tamanho)
.addKeyValue("duracao_ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - inicio))
.log("listagem de auditoria encerrada");
}
}
}

@GetMapping(path = "/auditoria/jobs/{id}", produces = "application/json")
RegistroAuditoriaDto consultar(@PathVariable UUID id,
@RequestAttribute(AcessoAuditoriaInterceptor.ACESSO) AcessoDoUsuario acesso) {
try (var escopo = this.correlacao.abrir(id.toString(), acesso.usuarioId().toString())) {
long inicio = System.nanoTime();
String resultado = "falha";
try {
RegistroAuditoriaDto registro = this.service.consultar(id);
resultado = "sucesso";
return registro;
}
catch (JobNaoEncontradoNaAuditoriaException ex) {
resultado = "job_nao_encontrado";
throw ex;
}
finally {
log.atLevel(nivel(resultado))
.addKeyValue("operacao", "consultar_job_auditoria")
.addKeyValue("resultado", resultado)
.addKeyValue("duracao_ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - inicio))
.log("consulta de auditoria encerrada");
}
}
}

private static Level nivel(String resultado) {
return switch (resultado) {
case "sucesso" -> Level.INFO;
case "falha" -> Level.ERROR;
default -> Level.WARN;
};
}

}
Loading
Loading