diff --git a/api/.agents/skills/sse/SKILL.md b/api/.agents/skills/sse/SKILL.md index 20ebe5b..e83a9c1 100644 --- a/api/.agents/skills/sse/SKILL.md +++ b/api/.agents/skills/sse/SKILL.md @@ -63,10 +63,13 @@ emissor é removido). - **Autenticação do stream.** Com Keycloak habilitado, `GET /jobs/{id}/events` exige JWT Bearer, como as demais rotas de jobs. O frontend usa `fetch` para enviar o cabeçalho e renova o token antes de abrir ou reconectar o stream. -- **Autorização em tarefa própria.** O controller já chama `AutorizadorDeJob`; - preserve essa checagem ao alterar o transporte. A cobertura uniforme de posse, - papéis e respostas de acesso negado pertence à tarefa do middleware de - autorização, conforme o [ADR-006](../../../../docs/adrs/ADR-006.md). +- **Autorização antes do stream.** O método do controller declara + `@AutorizarJob(OperacaoJob.ACOMPANHAR)`. O interceptor aplica `AutorizadorDeJob` + antes de executar o controller ou criar `SseEmitter`: somente profissional de + RH dono do job pode abrir o stream. Cada abertura ou reconexão repete a checagem; + auditor, papel inválido e não dono recebem 403 JSON. Preserve essa ordem ao + alterar o transporte, conforme a matriz HTTP da + [DEC-087](../../../../docs/decisoes/dec-087.md). ## Referências diff --git a/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java b/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java index d03ee14..e5436b2 100644 --- a/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java +++ b/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java @@ -3,5 +3,5 @@ import java.util.UUID; /** Identidade local associada à sessão autenticada. */ -public record AcessoDoUsuario(UUID usuarioId, boolean auditor) { +public record AcessoDoUsuario(UUID usuarioId, PapelDoUsuario papel) { } diff --git a/api/src/main/java/synapse/api/core/security/PapelDoUsuario.java b/api/src/main/java/synapse/api/core/security/PapelDoUsuario.java new file mode 100644 index 0000000..2e09167 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/PapelDoUsuario.java @@ -0,0 +1,27 @@ +package synapse.api.core.security; + +import org.springframework.security.access.AccessDeniedException; + +public enum PapelDoUsuario { + + PROFISSIONAL_RH("profissional_rh"), AUDITOR("auditor"); + + private final String coluna; + + PapelDoUsuario(String coluna) { + this.coluna = coluna; + } + + public String paraColuna() { + return this.coluna; + } + + public static PapelDoUsuario deColuna(String papel) { + return switch (papel) { + case "profissional_rh" -> PROFISSIONAL_RH; + case "auditor" -> AUDITOR; + default -> throw new AccessDeniedException("Papel não reconhecido."); + }; + } + +} diff --git a/api/src/main/java/synapse/api/core/security/UsuarioAtual.java b/api/src/main/java/synapse/api/core/security/UsuarioAtual.java index d84c310..e61253e 100644 --- a/api/src/main/java/synapse/api/core/security/UsuarioAtual.java +++ b/api/src/main/java/synapse/api/core/security/UsuarioAtual.java @@ -36,30 +36,33 @@ public AcessoDoUsuario obter() { return usuarioDeDesenvolvimento(); } + PapelDoUsuario papel = papelDoToken(token); String subject = Objects.requireNonNull(token.getSubject()); String login = textoDoToken(token, "preferred_username", subject); String nome = textoDoToken(token, "name", login); - UUID usuarioId = encontrarOuCriar(subject, login, nome); - return new AcessoDoUsuario(usuarioId, temPapelDeAuditor(token)); + UUID usuarioId = encontrarOuCriar(subject, login, nome, papel); + return new AcessoDoUsuario(usuarioId, papel); } private AcessoDoUsuario usuarioDeDesenvolvimento() { if (this.properties.keycloak().enabled()) { throw new IllegalStateException("Sessão autenticada não encontrada."); } - List usuarios = this.jdbc - .queryForList("SELECT id FROM usuarios WHERE ativo = true ORDER BY criado_em, id LIMIT 1", UUID.class); + List usuarios = this.jdbc.query( + "SELECT id, papel FROM usuarios WHERE ativo = true ORDER BY criado_em, id LIMIT 1", + (linha, numero) -> new AcessoDoUsuario(Objects.requireNonNull(linha.getObject("id", UUID.class)), + PapelDoUsuario.deColuna(Objects.requireNonNull(linha.getString("papel"))))); if (usuarios.isEmpty()) { throw new IllegalStateException("Nenhum usuário ativo disponível."); } - return new AcessoDoUsuario(usuarios.getFirst(), false); + return usuarios.getFirst(); } - private UUID encontrarOuCriar(String subject, String login, String nome) { + private UUID encontrarOuCriar(String subject, String login, String nome, PapelDoUsuario papel) { List usuarios = this.jdbc.queryForList(""" SELECT id FROM usuarios WHERE keycloak_sub = ? OR (keycloak_sub IS NULL AND login = ?) - ORDER BY criado_em, id LIMIT 1 + ORDER BY CASE WHEN keycloak_sub IS NULL THEN 1 ELSE 0 END, criado_em, id LIMIT 1 """, UUID.class, subject, login); Timestamp agora = Timestamp.from(Instant.now()); if (!usuarios.isEmpty()) { @@ -75,8 +78,8 @@ private UUID encontrarOuCriar(String subject, String login, String nome) { } return Objects.requireNonNull(this.jdbc.queryForObject(""" INSERT INTO usuarios (login, senha_hash, nome, papel, ativo, criado_em, ultimo_login_em, keycloak_sub) - VALUES (?, NULL, ?, 'profissional_rh', true, ?, ?, ?) RETURNING id - """, UUID.class, login, nome, agora, agora, subject)); + VALUES (?, NULL, ?, ?, true, ?, ?, ?) RETURNING id + """, UUID.class, login, nome, papel.paraColuna(), agora, agora, subject)); } private static String textoDoToken(Jwt token, String campo, String padrao) { @@ -84,12 +87,17 @@ private static String textoDoToken(Jwt token, String campo, String padrao) { return valor == null || valor.isBlank() ? padrao : valor; } - private static boolean temPapelDeAuditor(Jwt token) { - Map acessoDoRealm = token.getClaimAsMap("realm_access"); - if (acessoDoRealm == null || !(acessoDoRealm.get("roles") instanceof List papeis)) { - return false; + private static PapelDoUsuario papelDoToken(Jwt token) { + Object acessoDoRealm = token.getClaim("realm_access"); + if (!(acessoDoRealm instanceof Map realm) || !(realm.get("roles") instanceof List papeis)) { + throw new AccessDeniedException("Papel não reconhecido."); } - return papeis.stream().anyMatch("auditor"::equals); + boolean rh = papeis.contains("profissional-rh"); + boolean auditor = papeis.contains("auditor"); + if (rh == auditor) { + throw new AccessDeniedException("Exatamente um papel de negócio é obrigatório."); + } + return rh ? PapelDoUsuario.PROFISSIONAL_RH : PapelDoUsuario.AUDITOR; } } diff --git a/api/src/main/java/synapse/api/job/AcompanharJobController.java b/api/src/main/java/synapse/api/job/AcompanharJobController.java index 30c480e..feca69c 100644 --- a/api/src/main/java/synapse/api/job/AcompanharJobController.java +++ b/api/src/main/java/synapse/api/job/AcompanharJobController.java @@ -11,7 +11,6 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import synapse.api.core.logging.CorrelationContext; -import synapse.api.core.security.UsuarioAtual; @RestController class AcompanharJobController { @@ -20,21 +19,14 @@ class AcompanharJobController { private final CorrelationContext correlacao; - private final AutorizadorDeJob autorizador; - - private final UsuarioAtual usuarioAtual; - - AcompanharJobController(AcompanharJobService service, CorrelationContext correlacao, AutorizadorDeJob autorizador, - UsuarioAtual usuarioAtual) { + AcompanharJobController(AcompanharJobService service, CorrelationContext correlacao) { this.service = service; this.correlacao = correlacao; - this.autorizador = autorizador; - this.usuarioAtual = usuarioAtual; } + @AutorizarJob(OperacaoJob.ACOMPANHAR) @GetMapping(path = "/jobs/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) ResponseEntity acompanhar(@PathVariable("id") UUID id) { - this.autorizador.exigirAcesso(id, this.usuarioAtual.obter()); try (var escopo = this.correlacao.abrir(id.toString(), null)) { SseEmitter emissor = this.service.acompanhar(id); return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(emissor); diff --git a/api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java b/api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java index b9bb621..a21f024 100644 --- a/api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java +++ b/api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java @@ -1,6 +1,8 @@ package synapse.api.job; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -8,10 +10,11 @@ @RestControllerAdvice class AutorizacaoDeJobAdvice { - @ExceptionHandler(SemPermissaoNoJobException.class) + @ExceptionHandler({ SemPermissaoNoJobException.class, AccessDeniedException.class }) ResponseEntity semPermissao() { return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(new ErroDto("sem_permissao", "Você não tem acesso a este job.")); + .contentType(MediaType.APPLICATION_JSON) + .body(new ErroDto("sem_permissao", "Você não tem permissão para esta ação.")); } } diff --git a/api/src/main/java/synapse/api/job/AutorizacaoJobsConfig.java b/api/src/main/java/synapse/api/job/AutorizacaoJobsConfig.java new file mode 100644 index 0000000..c60ff0e --- /dev/null +++ b/api/src/main/java/synapse/api/job/AutorizacaoJobsConfig.java @@ -0,0 +1,21 @@ +package synapse.api.job; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +class AutorizacaoJobsConfig implements WebMvcConfigurer { + + private final AutorizacaoJobsInterceptor interceptor; + + AutorizacaoJobsConfig(AutorizacaoJobsInterceptor interceptor) { + this.interceptor = interceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(this.interceptor).addPathPatterns("/jobs", "/jobs/**"); + } + +} diff --git a/api/src/main/java/synapse/api/job/AutorizacaoJobsInterceptor.java b/api/src/main/java/synapse/api/job/AutorizacaoJobsInterceptor.java new file mode 100644 index 0000000..f45518f --- /dev/null +++ b/api/src/main/java/synapse/api/job/AutorizacaoJobsInterceptor.java @@ -0,0 +1,63 @@ +package synapse.api.job; + +import java.util.Map; +import java.util.UUID; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; + +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + +@Component +class AutorizacaoJobsInterceptor implements HandlerInterceptor { + + static final String ACESSO = "acessoAutorizadoAoJob"; + + private final UsuarioAtual usuarioAtual; + + private final AutorizadorDeJob autorizador; + + AutorizacaoJobsInterceptor(UsuarioAtual usuarioAtual, AutorizadorDeJob autorizador) { + this.usuarioAtual = usuarioAtual; + this.autorizador = autorizador; + } + + @Override + public boolean preHandle(HttpServletRequest requisicao, HttpServletResponse resposta, Object handler) { + if (CorsUtils.isPreFlightRequest(requisicao)) { + return true; + } + if (!(handler instanceof HandlerMethod metodo)) { + throw new SemPermissaoNoJobException(); + } + AutorizarJob politica = metodo.getMethodAnnotation(AutorizarJob.class); + if (politica == null) { + throw new SemPermissaoNoJobException(); + } + AcessoDoUsuario acesso = this.usuarioAtual.obter(); + Object variaveis = requisicao.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + UUID jobId = null; + if (politica.value().exigePosse() && variaveis instanceof Map mapa + && mapa.get("id") instanceof String id) { + try { + jobId = UUID.fromString(id); + } + catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + } + this.autorizador.exigir(politica.value(), jobId, acesso); + requisicao.setAttribute(ACESSO, acesso); + return true; + } + +} diff --git a/api/src/main/java/synapse/api/job/AutorizadorDeJob.java b/api/src/main/java/synapse/api/job/AutorizadorDeJob.java index bd4c6ee..5bf629b 100644 --- a/api/src/main/java/synapse/api/job/AutorizadorDeJob.java +++ b/api/src/main/java/synapse/api/job/AutorizadorDeJob.java @@ -3,10 +3,13 @@ import java.util.List; import java.util.UUID; +import org.jspecify.annotations.Nullable; + import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.PapelDoUsuario; @Service class AutorizadorDeJob { @@ -17,12 +20,25 @@ class AutorizadorDeJob { this.jdbc = jdbc; } - void exigirAcesso(UUID jobId, AcessoDoUsuario acesso) { + void exigir(OperacaoJob operacao, @Nullable UUID jobId, AcessoDoUsuario acesso) { + boolean permitido = switch (operacao) { + case CRIAR, LISTAR, CONSULTAR, ACOMPANHAR, CONFIRMAR_PARAMETROS, EXECUTAR_ACAO, REPROCESSAR -> + acesso.papel() == PapelDoUsuario.PROFISSIONAL_RH; + }; + if (!permitido) { + throw new SemPermissaoNoJobException(); + } + if (!operacao.exigePosse()) { + return; + } + if (jobId == null) { + throw new SemPermissaoNoJobException(); + } List donos = this.jdbc.queryForList("SELECT usuario_id FROM jobs WHERE id = ?", UUID.class, jobId); if (donos.isEmpty()) { throw new JobNaoEncontradoException(jobId); } - if (!acesso.auditor() && !acesso.usuarioId().equals(donos.getFirst())) { + if (!acesso.usuarioId().equals(donos.getFirst())) { throw new SemPermissaoNoJobException(); } } diff --git a/api/src/main/java/synapse/api/job/AutorizarJob.java b/api/src/main/java/synapse/api/job/AutorizarJob.java new file mode 100644 index 0000000..85ce61d --- /dev/null +++ b/api/src/main/java/synapse/api/job/AutorizarJob.java @@ -0,0 +1,14 @@ +package synapse.api.job; + +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) +@interface AutorizarJob { + + OperacaoJob value(); + +} diff --git a/api/src/main/java/synapse/api/job/BuscarJobController.java b/api/src/main/java/synapse/api/job/BuscarJobController.java index bfdaa8b..53f95b8 100644 --- a/api/src/main/java/synapse/api/job/BuscarJobController.java +++ b/api/src/main/java/synapse/api/job/BuscarJobController.java @@ -5,26 +5,18 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; -import synapse.api.core.security.UsuarioAtual; - @RestController class BuscarJobController { private final BuscarJobService service; - private final AutorizadorDeJob autorizador; - - private final UsuarioAtual usuarioAtual; - - BuscarJobController(BuscarJobService service, AutorizadorDeJob autorizador, UsuarioAtual usuarioAtual) { + BuscarJobController(BuscarJobService service) { this.service = service; - this.autorizador = autorizador; - this.usuarioAtual = usuarioAtual; } + @AutorizarJob(OperacaoJob.CONSULTAR) @GetMapping(path = "/jobs/{id}", produces = "application/json") JobDetalhadoDto buscar(@PathVariable UUID id) { - this.autorizador.exigirAcesso(id, this.usuarioAtual.obter()); return this.service.buscar(id); } diff --git a/api/src/main/java/synapse/api/job/ConfirmarParametrosController.java b/api/src/main/java/synapse/api/job/ConfirmarParametrosController.java index bdca9a2..3769c64 100644 --- a/api/src/main/java/synapse/api/job/ConfirmarParametrosController.java +++ b/api/src/main/java/synapse/api/job/ConfirmarParametrosController.java @@ -18,6 +18,7 @@ class ConfirmarParametrosController { this.service = service; } + @AutorizarJob(OperacaoJob.CONFIRMAR_PARAMETROS) @PostMapping(path = "/jobs/{id}/parameters", consumes = "application/json", produces = "application/json") ResponseEntity confirmar(@PathVariable("id") UUID id, @RequestBody String corpo) { JobCriadoDto job = this.service.confirmar(id, ConfirmarParametrosRequisicao.deJson(corpo)); diff --git a/api/src/main/java/synapse/api/job/CriarJobController.java b/api/src/main/java/synapse/api/job/CriarJobController.java index c9c6e99..9ba503d 100644 --- a/api/src/main/java/synapse/api/job/CriarJobController.java +++ b/api/src/main/java/synapse/api/job/CriarJobController.java @@ -6,24 +6,24 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestAttribute; -import synapse.api.core.security.UsuarioAtual; +import synapse.api.core.security.AcessoDoUsuario; @RestController class CriarJobController { private final CriarJobService service; - private final UsuarioAtual usuarioAtual; - - CriarJobController(CriarJobService service, UsuarioAtual usuarioAtual) { + CriarJobController(CriarJobService service) { this.service = service; - this.usuarioAtual = usuarioAtual; } + @AutorizarJob(OperacaoJob.CRIAR) @PostMapping(path = "/jobs", consumes = "application/json", produces = "application/json") - ResponseEntity criar(@RequestBody String corpo) { - JobCriadoDto job = this.service.criar(CriarJobRequisicao.deJson(corpo), this.usuarioAtual.obter().usuarioId()); + ResponseEntity criar(@RequestBody String corpo, + @RequestAttribute(AutorizacaoJobsInterceptor.ACESSO) AcessoDoUsuario acesso) { + JobCriadoDto job = this.service.criar(CriarJobRequisicao.deJson(corpo), acesso.usuarioId()); return ResponseEntity.created(URI.create("/api/jobs/" + job.id())).body(job); } diff --git a/api/src/main/java/synapse/api/job/ExecutarAcaoController.java b/api/src/main/java/synapse/api/job/ExecutarAcaoController.java index 5a48160..c64cc0e 100644 --- a/api/src/main/java/synapse/api/job/ExecutarAcaoController.java +++ b/api/src/main/java/synapse/api/job/ExecutarAcaoController.java @@ -9,7 +9,6 @@ import synapse.api.core.sse.EmissoresSse; import synapse.api.core.sse.EventoSse; -import synapse.api.core.security.UsuarioAtual; import synapse.api.job.ExecutarAcaoService.AcaoAplicada; @RestController @@ -19,21 +18,14 @@ class ExecutarAcaoController { private final EmissoresSse emissores; - private final AutorizadorDeJob autorizador; - - private final UsuarioAtual usuarioAtual; - - ExecutarAcaoController(ExecutarAcaoService service, EmissoresSse emissores, AutorizadorDeJob autorizador, - UsuarioAtual usuarioAtual) { + ExecutarAcaoController(ExecutarAcaoService service, EmissoresSse emissores) { this.service = service; this.emissores = emissores; - this.autorizador = autorizador; - this.usuarioAtual = usuarioAtual; } + @AutorizarJob(OperacaoJob.EXECUTAR_ACAO) @PostMapping(path = "/jobs/{id}/actions", consumes = "application/json", produces = "application/json") JobDetalhadoDto executar(@PathVariable UUID id, @RequestBody String corpo) { - this.autorizador.exigirAcesso(id, this.usuarioAtual.obter()); AcaoJob acao = ExecutarAcaoRequisicao.deJson(corpo).acao(); AcaoAplicada aplicada = this.service.aplicar(id, acao); // Os três destinos das ações de finalização (liberado, cancelado, arquivado) são diff --git a/api/src/main/java/synapse/api/job/ListarJobsController.java b/api/src/main/java/synapse/api/job/ListarJobsController.java index 75cb797..81ce17d 100644 --- a/api/src/main/java/synapse/api/job/ListarJobsController.java +++ b/api/src/main/java/synapse/api/job/ListarJobsController.java @@ -3,25 +3,25 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestAttribute; -import synapse.api.core.security.UsuarioAtual; +import synapse.api.core.security.AcessoDoUsuario; @RestController class ListarJobsController { private final ListarJobsService service; - private final UsuarioAtual usuarioAtual; - - ListarJobsController(ListarJobsService service, UsuarioAtual usuarioAtual) { + ListarJobsController(ListarJobsService service) { this.service = service; - this.usuarioAtual = usuarioAtual; } + @AutorizarJob(OperacaoJob.LISTAR) @GetMapping(path = "/jobs", produces = "application/json") PaginaJobsDto listar(@RequestParam(name = "pagina", defaultValue = "0") int pagina, - @RequestParam(name = "tamanho", defaultValue = "20") int tamanho) { - return this.service.listar(new ListarJobsRequisicao(pagina, tamanho), this.usuarioAtual.obter()); + @RequestParam(name = "tamanho", defaultValue = "20") int tamanho, + @RequestAttribute(AutorizacaoJobsInterceptor.ACESSO) AcessoDoUsuario acesso) { + return this.service.listar(new ListarJobsRequisicao(pagina, tamanho), acesso); } } diff --git a/api/src/main/java/synapse/api/job/ListarJobsService.java b/api/src/main/java/synapse/api/job/ListarJobsService.java index 424af92..a8711c3 100644 --- a/api/src/main/java/synapse/api/job/ListarJobsService.java +++ b/api/src/main/java/synapse/api/job/ListarJobsService.java @@ -27,11 +27,6 @@ class ListarJobsService { this.jdbc = jdbc; } - @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) - PaginaJobsDto listar(ListarJobsRequisicao requisicao) { - return listar(requisicao, new AcessoDoUsuario(UUID.randomUUID(), true)); - } - /** * {@code REPEATABLE_READ} faz a contagem e a página lerem o mesmo snapshot. O * veredito é o da simulação mais recente do job, e só quando ela terminou com @@ -40,8 +35,8 @@ PaginaJobsDto listar(ListarJobsRequisicao requisicao) { @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) PaginaJobsDto listar(ListarJobsRequisicao requisicao, AcessoDoUsuario acesso) { long total = Objects.requireNonNull(this.jdbc.queryForObject(""" - SELECT count(*) FROM jobs WHERE ? OR usuario_id = ? - """, Long.class, acesso.auditor(), acesso.usuarioId())); + SELECT count(*) FROM jobs WHERE usuario_id = ? + """, Long.class, acesso.usuarioId())); List itens = this.jdbc.query(""" SELECT j.id, j.status, j.competencias, j.orcamento, j.criado_em, j.finalizado_em, j.job_origem_id, rs.veredito @@ -53,10 +48,10 @@ LEFT JOIN LATERAL ( LIMIT 1 ) corrente ON true LEFT JOIN resultados_simulacao rs ON rs.id = corrente.resultado_id AND rs.status = 'sucesso' - WHERE ? OR j.usuario_id = ? + WHERE j.usuario_id = ? ORDER BY j.criado_em DESC, j.id DESC LIMIT ? OFFSET ? - """, (linha, numero) -> resumo(linha), acesso.auditor(), acesso.usuarioId(), requisicao.tamanho(), + """, (linha, numero) -> resumo(linha), acesso.usuarioId(), requisicao.tamanho(), requisicao.deslocamento()); return new PaginaJobsDto(itens, requisicao.pagina(), requisicao.tamanho(), total); } diff --git a/api/src/main/java/synapse/api/job/OperacaoJob.java b/api/src/main/java/synapse/api/job/OperacaoJob.java new file mode 100644 index 0000000..65fbc32 --- /dev/null +++ b/api/src/main/java/synapse/api/job/OperacaoJob.java @@ -0,0 +1,14 @@ +package synapse.api.job; + +enum OperacaoJob { + + CRIAR, LISTAR, CONSULTAR, ACOMPANHAR, CONFIRMAR_PARAMETROS, EXECUTAR_ACAO, REPROCESSAR; + + boolean exigePosse() { + return switch (this) { + case CRIAR, LISTAR -> false; + case CONSULTAR, ACOMPANHAR, CONFIRMAR_PARAMETROS, EXECUTAR_ACAO, REPROCESSAR -> true; + }; + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobController.java b/api/src/main/java/synapse/api/job/ReprocessarJobController.java index 9c59521..85c8dfd 100644 --- a/api/src/main/java/synapse/api/job/ReprocessarJobController.java +++ b/api/src/main/java/synapse/api/job/ReprocessarJobController.java @@ -20,6 +20,7 @@ class ReprocessarJobController { this.service = service; } + @AutorizarJob(OperacaoJob.REPROCESSAR) @PostMapping(path = "/jobs/{id}/reprocessar", produces = "application/json") ResponseEntity reprocessar(@PathVariable("id") UUID id, @RequestBody(required = false) @Nullable String corpo) { diff --git a/api/src/test/java/synapse/api/core/security/UsuarioAtualTests.java b/api/src/test/java/synapse/api/core/security/UsuarioAtualTests.java new file mode 100644 index 0000000..7a91f17 --- /dev/null +++ b/api/src/test/java/synapse/api/core/security/UsuarioAtualTests.java @@ -0,0 +1,129 @@ +package synapse.api.core.security; + +import java.sql.ResultSet; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.EnumSource; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import synapse.api.core.config.AppProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class UsuarioAtualTests { + + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + + private final AppProperties properties = mock(AppProperties.class); + + private final UsuarioAtual usuarioAtual = new UsuarioAtual(this.jdbc, this.properties); + + @AfterEach + void limparSessao() { + SecurityContextHolder.clearContext(); + } + + static Stream papeisValidos() { + return Stream.of(Arguments.of(List.of("profissional-rh"), PapelDoUsuario.PROFISSIONAL_RH), + Arguments.of(List.of("auditor"), PapelDoUsuario.AUDITOR), + Arguments.of(List.of("default-roles-synapse", "offline_access", "profissional-rh"), + PapelDoUsuario.PROFISSIONAL_RH), + Arguments.of(List.of("default-roles-synapse", "offline_access", "auditor"), PapelDoUsuario.AUDITOR)); + } + + @ParameterizedTest + @MethodSource("papeisValidos") + void resolvePapelAntesDeCriarContaComPapelCorrespondente(List papeis, PapelDoUsuario esperado) { + autenticar(Map.of("roles", papeis)); + UUID id = UUID.randomUUID(); + when(this.jdbc.queryForList(anyString(), eq(UUID.class), eq("subject-a"), eq("login-a"))).thenReturn(List.of()); + when(this.jdbc.queryForObject(contains("INSERT INTO usuarios"), eq(UUID.class), eq("login-a"), eq("Nome A"), + eq(esperado.paraColuna()), any(), any(), eq("subject-a"))) + .thenReturn(id); + + assertThat(this.usuarioAtual.obter()).isEqualTo(new AcessoDoUsuario(id, esperado)); + verify(this.jdbc).queryForObject(contains("INSERT INTO usuarios"), eq(UUID.class), eq("login-a"), eq("Nome A"), + eq(esperado.paraColuna()), any(), any(), eq("subject-a")); + } + + static Stream papeisInvalidos() { + return Stream.of(Map.of(), Map.of("roles", List.of()), Map.of("roles", List.of("offline_access")), + Map.of("roles", List.of("profissional-rh", "auditor")), + Map.of("roles", List.of("profissional-rh", "auditor", "offline_access")), + Map.of("roles", "profissional-rh"), "realm-invalido"); + } + + @ParameterizedTest + @MethodSource("papeisInvalidos") + void rejeitaSemQualquerConsultaInsertOuUpdateLocal(Object realm) { + autenticar(realm); + assertThatThrownBy(this.usuarioAtual::obter).isInstanceOf(AccessDeniedException.class); + verifyNoInteractions(this.jdbc); + } + + @Test + void realmAusenteTambemNaoTocaBanco() { + SecurityContextHolder.getContext() + .setAuthentication(new JwtAuthenticationToken( + Jwt.withTokenValue("teste").header("alg", "RS256").subject("subject-a").build())); + assertThatThrownBy(this.usuarioAtual::obter).isInstanceOf(AccessDeniedException.class); + verifyNoInteractions(this.jdbc); + } + + @Test + void contaExistenteUsaSubEPapelDoTokenSemSincronizarPapelLocal() { + autenticar(Map.of("roles", List.of("auditor"))); + UUID id = UUID.randomUUID(); + when(this.jdbc.queryForList(anyString(), eq(UUID.class), eq("subject-a"), eq("login-a"))) + .thenReturn(List.of(id)); + when(this.jdbc.update(contains("UPDATE usuarios"), eq("subject-a"), eq("Nome A"), any(), eq(id))).thenReturn(1); + assertThat(this.usuarioAtual.obter()).isEqualTo(new AcessoDoUsuario(id, PapelDoUsuario.AUDITOR)); + verify(this.jdbc).update(argThat(sql -> !sql.contains("papel")), eq("subject-a"), eq("Nome A"), any(), eq(id)); + } + + @ParameterizedTest + @EnumSource(PapelDoUsuario.class) + void desenvolvimentoLeOPapelCanonicoLocal(PapelDoUsuario papel) throws Exception { + when(this.properties.keycloak()).thenReturn(new AppProperties.Keycloak(false, "issuer", "jwks")); + UUID id = UUID.randomUUID(); + ResultSet linha = mock(ResultSet.class); + when(linha.getObject("id", UUID.class)).thenReturn(id); + when(linha.getString("papel")).thenReturn(papel.paraColuna()); + when(this.jdbc.query(contains("SELECT id, papel"), + org.mockito.ArgumentMatchers.>any())) + .thenAnswer(invocacao -> { + RowMapper mapper = invocacao.getArgument(1); + return List.of(mapper.mapRow(linha, 0)); + }); + assertThat(this.usuarioAtual.obter()).isEqualTo(new AcessoDoUsuario(id, papel)); + } + + private static void autenticar(Object realm) { + Jwt token = Jwt.withTokenValue("teste") + .header("alg", "RS256") + .subject("subject-a") + .claim("preferred_username", "login-a") + .claim("name", "Nome A") + .claim("realm_access", realm) + .build(); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(token)); + } + +} diff --git a/api/src/test/java/synapse/api/core/web/CorsConfigTests.java b/api/src/test/java/synapse/api/core/web/CorsConfigTests.java index e55514f..3f85956 100644 --- a/api/src/test/java/synapse/api/core/web/CorsConfigTests.java +++ b/api/src/test/java/synapse/api/core/web/CorsConfigTests.java @@ -76,7 +76,9 @@ void rejectsThePreflightOfAnOriginOutsideTheList() { .expectStatus() .isForbidden() .expectHeader() - .doesNotExist("Access-Control-Allow-Origin"); + .doesNotExist("Access-Control-Allow-Origin") + .expectBody(String.class) + .isEqualTo("Invalid CORS request"); } /** O contrato em contracts/http/ só tem GET e POST. */ diff --git a/api/src/test/java/synapse/api/core/web/CorsWithoutOriginsTests.java b/api/src/test/java/synapse/api/core/web/CorsWithoutOriginsTests.java index 26008ff..e48f8ec 100644 --- a/api/src/test/java/synapse/api/core/web/CorsWithoutOriginsTests.java +++ b/api/src/test/java/synapse/api/core/web/CorsWithoutOriginsTests.java @@ -35,6 +35,8 @@ void liberatesNoOriginAtAll() { .header("Origin", "https://app.exemplo.com") .header("Access-Control-Request-Method", "POST") .exchange() + .expectStatus() + .isOk() .expectHeader() .doesNotExist("Access-Control-Allow-Origin"); } diff --git a/api/src/test/java/synapse/api/job/AutorizacaoJobsPersistenciaTests.java b/api/src/test/java/synapse/api/job/AutorizacaoJobsPersistenciaTests.java new file mode 100644 index 0000000..b440276 --- /dev/null +++ b/api/src/test/java/synapse/api/job/AutorizacaoJobsPersistenciaTests.java @@ -0,0 +1,396 @@ +package synapse.api.job; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Stream; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import synapse.api.ApiApplication; +import synapse.api.core.sse.EmissoresSse; +import synapse.api.core.sse.EventoSse; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIf("dockerIsAvailable") +class AutorizacaoJobsPersistenciaTests { + + private static final UUID A = UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + + private static final UUID B = UUID.fromString("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + + private static final String ISSUER = "https://emissor-de-teste.invalid/realms/synapse"; + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + private static PostgreSQLContainer postgres; + + private static ConfigurableApplicationContext contexto; + + private static HttpServer jwks; + + private static RSAKey chave; + + private static JdbcTemplate dono; + + private static String base; + + static boolean dockerIsAvailable() { + return DockerClientFactory.instance().isDockerAvailable(); + } + + @BeforeAll + static void iniciar() throws Exception { + chave = new RSAKeyGenerator(2048).keyID("teste").generate(); + jwks = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + jwks.createContext("/jwks", exchange -> { + byte[] corpo = new JWKSet(chave.toPublicJWK()).toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, corpo.length); + try (var saida = exchange.getResponseBody()) { + saida.write(corpo); + } + }); + jwks.start(); + postgres = new PostgreSQLContainer("postgres:18-alpine"); + postgres.start(); + contexto = new SpringApplicationBuilder(ApiApplication.class, RotasDeTeste.class) + .web(WebApplicationType.SERVLET) + .run("--server.port=0", "--app.keycloak.enabled=true", "--app.keycloak.issuer-uri=" + ISSUER, + "--app.keycloak.jwk-set-uri=http://127.0.0.1:" + jwks.getAddress().getPort() + "/jwks", + "--app.postgres.host=" + postgres.getHost(), "--app.postgres.port=" + postgres.getMappedPort(5432), + "--app.postgres.database=" + postgres.getDatabaseName(), + "--app.postgres.owner.user=" + postgres.getUsername(), + "--app.postgres.owner.password=" + postgres.getPassword(), "--app.postgres.user=synapse_api", + "--app.postgres.password=senha-de-teste", "--spring.rabbitmq.dynamic=false", + "--spring.rabbitmq.listener.simple.auto-startup=false", "--management.health.rabbit.enabled=false", + "--management.health.db.enabled=false", "--app.outbox.enabled=false"); + dono = new JdbcTemplate( + new DriverManagerDataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())); + dono.execute("ALTER ROLE synapse_api WITH PASSWORD 'senha-de-teste'"); + base = "http://localhost:" + contexto.getEnvironment().getProperty("local.server.port"); + } + + @AfterAll + static void encerrar() { + if (contexto != null) { + contexto.close(); + } + if (postgres != null) { + postgres.stop(); + } + if (jwks != null) { + jwks.stop(0); + } + } + + @BeforeEach + void prepararUsuarios() { + dono.execute("TRUNCATE usuarios CASCADE"); + dono.update(""" + INSERT INTO usuarios (id, login, nome, papel, keycloak_sub, criado_em) + VALUES (?, 'a', 'A', 'profissional_rh', 'subject-a', now()), + (?, 'b', 'B', 'profissional_rh', 'subject-b', now()) + """, A, B); + } + + static Stream mutacoesNegadas() { + return Stream.of("criar", "parameters", "reprocessar", "confirmar_liberar", "salvar", "cancelar", "arquivar") + .flatMap(rota -> Stream.of("auditor", "alheio", "nenhum", "ambos") + .filter(perfil -> !rota.equals("criar") || !perfil.equals("alheio")) + .map(perfil -> Arguments.of(rota, perfil))); + } + + @ParameterizedTest(name = "{0} negado para {1} sem escrita") + @MethodSource("mutacoesNegadas") + void negacaoNaoAlteraJobsRegrasTransicoesAcoesTrilhasSubmissoesOuOutbox(String rota, String perfil) + throws Exception { + UUID job = criarJob(A, estadoPara(rota)); + String antes = snapshotNegocio(); + String usuarios = snapshotUsuarios(); + List papeis = switch (perfil) { + case "auditor" -> List.of("auditor", "offline_access"); + case "nenhum" -> List.of("offline_access"); + case "ambos" -> List.of("profissional-rh", "auditor"); + default -> List.of("profissional-rh"); + }; + String token = token(perfil.equals("alheio") ? "subject-b" : "subject-a", papeis); + HttpResponse resposta = chamar("POST", caminho(rota, job), corpo(rota), token); + assertThat(resposta.statusCode()).isEqualTo(403); + assertThat(new JsonMapper().readTree(resposta.body()).path("codigo").asString()).isEqualTo("sem_permissao"); + assertThat(snapshotNegocio()).isEqualTo(antes); + if (perfil.equals("nenhum") || perfil.equals("ambos")) { + assertThat(snapshotUsuarios()).isEqualTo(usuarios); + } + } + + @ParameterizedTest + @ValueSource(strings = { "parameters", "reprocessar", "confirmar_liberar", "salvar", "cancelar", "arquivar" }) + void donoRhExecutaMutacaoNoEstadoPermitido(String rota) throws Exception { + UUID job = criarJob(A, estadoPara(rota)); + HttpResponse resposta = chamar("POST", caminho(rota, job), corpo(rota), + token("subject-a", List.of("profissional-rh"))); + assertThat(resposta.statusCode()) + .isEqualTo(rota.equals("parameters") ? 202 : rota.equals("reprocessar") ? 201 : 200); + if (rota.equals("reprocessar")) { + UUID novo = UUID.fromString(new JsonMapper().readTree(resposta.body()).path("id").asString()); + assertThat(dono.queryForObject("SELECT usuario_id FROM jobs WHERE id = ?", UUID.class, novo)).isEqualTo(A); + assertThat(dono.queryForObject("SELECT job_origem_id FROM jobs WHERE id = ?", UUID.class, novo)) + .isEqualTo(job); + } + else if (rota.equals("parameters")) { + assertThat(dono.queryForObject("SELECT count(*) FROM regras WHERE job_id = ?", Long.class, job)) + .isEqualTo(2); + } + else { + assertThat(dono.queryForObject("SELECT acao FROM job_acoes WHERE job_id = ?", String.class, job)) + .isEqualTo(rota); + } + } + + @Test + void listaSomentePropriosEConsultaConferePossePersistida() throws Exception { + UUID proprio = criarJob(A, "gerando_regra"); + UUID alheio = criarJob(B, "gerando_regra"); + String token = token("subject-a", List.of("profissional-rh", "offline_access")); + HttpResponse lista = chamar("GET", "/jobs?usuario_id=" + B, "", token); + assertThat(lista.statusCode()).isEqualTo(200); + var pagina = new JsonMapper().readTree(lista.body()); + assertThat(pagina.path("total").asLong()).isEqualTo(1); + assertThat(pagina.path("itens").size()).isEqualTo(1); + assertThat(pagina.path("itens").path(0).path("id").asString()).isEqualTo(proprio.toString()); + assertThat(chamar("GET", "/jobs/" + proprio, "", token).statusCode()).isEqualTo(200); + assertThat(chamar("GET", "/jobs/" + alheio + "?usuario_id=" + B, "", token).statusCode()).isEqualTo(403); + assertThat(chamar("GET", "/jobs/" + UUID.randomUUID(), "", token).statusCode()).isEqualTo(404); + } + + @Test + void criacaoUsaSubEIgnoraIdentidadeForjadaEPapelLocal() throws Exception { + dono.update("UPDATE usuarios SET papel = 'auditor' WHERE id = ?", A); + String corpo = CriarJobControllerTests.FORMULARIO.replace("\"origem\"", + "\"usuario_id\":\"" + B + "\",\"origem\""); + HttpResponse resposta = chamar("POST", "/jobs?usuario_id=" + B, corpo, + token("subject-a", List.of("profissional-rh"))); + assertThat(resposta.statusCode()).isEqualTo(201); + UUID job = UUID.fromString(new JsonMapper().readTree(resposta.body()).path("id").asString()); + assertThat(dono.queryForObject("SELECT usuario_id FROM jobs WHERE id = ?", UUID.class, job)).isEqualTo(A); + assertThat(dono.queryForObject("SELECT papel FROM usuarios WHERE id = ?", String.class, A)) + .isEqualTo("auditor"); + } + + @ParameterizedTest + @ValueSource(strings = { "profissional-rh", "auditor" }) + void novaContaPersistePapelCorrespondenteAoRealm(String papel) throws Exception { + HttpResponse resposta = chamar("GET", "/jobs", "", token("novo-sub", List.of(papel))); + assertThat(resposta.statusCode()).isEqualTo(papel.equals("auditor") ? 403 : 200); + assertThat(dono.queryForObject("SELECT papel FROM usuarios WHERE keycloak_sub = 'novo-sub'", String.class)) + .isEqualTo(papel.equals("auditor") ? "auditor" : "profissional_rh"); + } + + @ParameterizedTest + @ValueSource(strings = { "nenhum", "ambos" }) + void papelInvalidoNaoCriaContaNemAssociaContaLegada(String perfil) throws Exception { + dono.update("UPDATE usuarios SET keycloak_sub = NULL WHERE id = ?", A); + String antes = snapshotUsuarios(); + List papeis = perfil.equals("ambos") ? List.of("profissional-rh", "auditor") + : List.of("offline_access"); + assertThat(chamar("GET", "/jobs", "", token("subject-a", papeis)).statusCode()).isEqualTo(403); + assertThat(chamar("GET", "/jobs", "", token("novo-sub", papeis)).statusCode()).isEqualTo(403); + assertThat(snapshotUsuarios()).isEqualTo(antes); + } + + @Test + void subTemPrecedenciaSobreLoginLegado() throws Exception { + dono.update("UPDATE usuarios SET login = 'outro-login' WHERE id = ?", A); + dono.update("UPDATE usuarios SET login = 'a', keycloak_sub = NULL, criado_em = '2020-01-01' WHERE id = ?", B); + UUID proprio = criarJob(A, "gerando_regra"); + assertThat(chamar("GET", "/jobs/" + proprio, "", token("subject-a", List.of("profissional-rh"))).statusCode()) + .isEqualTo(200); + assertThat(dono.queryForObject("SELECT keycloak_sub IS NULL FROM usuarios WHERE id = ?", Boolean.class, B)) + .isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { "ausente", "invalido", "expirado", "issuer", "assinatura" }) + void autenticacaoInvalidaPreserva401SemEscrita(String caso) throws Exception { + String antes = snapshotUsuarios(); + String token = switch (caso) { + case "ausente" -> ""; + case "invalido" -> "nao-e-jwt"; + case "expirado" -> + assinar("subject-a", List.of("profissional-rh"), ISSUER, Instant.now().minusSeconds(300), chave); + case "issuer" -> assinar("subject-a", List.of("profissional-rh"), "https://outro.invalid", + Instant.now().plusSeconds(300), chave); + default -> assinar("subject-a", List.of("profissional-rh"), ISSUER, Instant.now().plusSeconds(300), + new RSAKeyGenerator(2048).keyID("teste").generate()); + }; + assertThat(chamar("GET", "/jobs", "", token).statusCode()).isEqualTo(401); + assertThat(snapshotUsuarios()).isEqualTo(antes); + } + + @Test + void registroRealDoInterceptorNegaRotaSemPolitica() throws Exception { + assertThat(chamar("GET", "/jobs/sem-politica", "", token("subject-a", List.of("profissional-rh"))).statusCode()) + .isEqualTo(403); + } + + @Test + void sseAutorizaTodaAberturaAntesDeRegistrarEmissor() throws Exception { + UUID job = criarJob(A, "gerando_regra"); + EmissoresSse emissores = contexto.getBean(EmissoresSse.class); + String caminho = "/jobs/" + job + "/events"; + HttpRequest pedido = HttpRequest.newBuilder(URI.create(base + caminho)) + .header("Accept", "text/event-stream") + .header("Authorization", "Bearer " + token("subject-a", List.of("profissional-rh"))) + .build(); + HttpResponse stream = HTTP.send(pedido, HttpResponse.BodyHandlers.ofInputStream()); + try (var entrada = stream.body()) { + assertThat(stream.statusCode()).isEqualTo(200); + assertThat(entrada.read()).isNotEqualTo(-1); + assertThat(emissores.conexoesAtivas()).isEqualTo(1); + assertThat(chamar("GET", caminho, "", token("subject-b", List.of("profissional-rh"))).statusCode()) + .isEqualTo(403); + assertThat(chamar("GET", caminho, "", token("subject-a", List.of("auditor"))).statusCode()).isEqualTo(403); + assertThat(emissores.conexoesAtivas()).isEqualTo(1); + } + finally { + emissores.emitir(job, EventoSse.ultimo("estado", Map.of("status", "cancelado"))); + } + dono.update("UPDATE jobs SET usuario_id = ? WHERE id = ?", B, job); + assertThat(chamar("GET", caminho, "", token("subject-a", List.of("profissional-rh"))).statusCode()) + .isEqualTo(403); + assertThat(emissores.conexoesAtivas()).isZero(); + } + + private static UUID criarJob(UUID usuario, String status) { + UUID id = contexto.getBean(CriarJobService.class) + .criar(CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO), usuario) + .id(); + dono.update("UPDATE jobs SET status = ? WHERE id = ?", status, id); + return id; + } + + private static String estadoPara(String rota) { + return switch (rota) { + case "parameters" -> "aguardando_confirmacao_parametros"; + case "reprocessar" -> "arquivado"; + case "arquivar" -> "simulacao_inviavel"; + default -> "aguardando_decisao_usuario"; + }; + } + + private static String caminho(String rota, UUID job) { + return switch (rota) { + case "criar" -> "/jobs"; + case "parameters", "reprocessar" -> "/jobs/" + job + "/" + rota; + default -> "/jobs/" + job + "/actions"; + }; + } + + private static String corpo(String rota) { + return switch (rota) { + case "criar" -> CriarJobControllerTests.FORMULARIO; + case "parameters" -> ConfirmarParametrosControllerTests.CONFIRMAR; + case "reprocessar" -> "{}"; + default -> "{\"acao\":\"" + rota + "\"}"; + }; + } + + private static HttpResponse chamar(String metodo, String caminho, String corpo, String token) + throws Exception { + HttpRequest.Builder pedido = HttpRequest.newBuilder(URI.create(base + caminho)) + .header("Content-Type", "application/json") + .method(metodo, + corpo.isEmpty() ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(corpo)); + if (!token.isEmpty()) { + pedido.header("Authorization", "Bearer " + token); + } + return HTTP.send(pedido.build(), HttpResponse.BodyHandlers.ofString()); + } + + private static String token(String sub, List papeis) throws Exception { + return assinar(sub, papeis, ISSUER, Instant.now().plusSeconds(300), chave); + } + + private static String assinar(String sub, List papeis, String issuer, Instant expira, RSAKey key) + throws Exception { + JWTClaimsSet claims = new JWTClaimsSet.Builder().subject(sub) + .issuer(issuer) + .issueTime(Date.from(Instant.now().minusSeconds(600))) + .expirationTime(Date.from(expira)) + .claim("preferred_username", sub.equals("subject-a") ? "a" : sub.equals("subject-b") ? "b" : sub) + .claim("realm_access", Map.of("roles", papeis)) + .build(); + SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("teste").build(), claims); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } + + private static String snapshotUsuarios() { + return Objects.requireNonNull(dono.queryForObject( + "SELECT coalesce(jsonb_agg(to_jsonb(u) ORDER BY id), '[]')::text FROM usuarios u", String.class)); + } + + private static String snapshotNegocio() { + return Objects.requireNonNull(dono.queryForObject(""" + SELECT jsonb_build_object( + 'jobs', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM jobs t), + 'regras', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM regras t), + 'transicoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM job_transicoes t), + 'acoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM job_acoes t), + 'trilhas', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM trilhas_auditoria t), + 'submissoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM submissoes t), + 'outbox', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM outbox_events t) + )::text + """, String.class)); + } + + @TestConfiguration(proxyBeanMethods = false) + static class RotasDeTeste { + + @Bean + AutorizacaoJobsTests.RotaSemPolitica rotaSemPolitica() { + return new AutorizacaoJobsTests.RotaSemPolitica(); + } + + } + +} diff --git a/api/src/test/java/synapse/api/job/AutorizacaoJobsTests.java b/api/src/test/java/synapse/api/job/AutorizacaoJobsTests.java new file mode 100644 index 0000000..5dcb211 --- /dev/null +++ b/api/src/test/java/synapse/api/job/AutorizacaoJobsTests.java @@ -0,0 +1,293 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import synapse.api.core.config.AppProperties; +import synapse.api.core.logging.CorrelationContext; +import synapse.api.core.security.UsuarioAtual; +import synapse.api.core.sse.EmissoresSse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class AutorizacaoJobsTests { + + static final UUID USUARIO = UUID.randomUUID(); + + static final UUID JOB = UUID.randomUUID(); + + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + + private final CriarJobService criar = mock(CriarJobService.class); + + private final ListarJobsService listar = mock(ListarJobsService.class); + + private final BuscarJobService buscar = mock(BuscarJobService.class); + + private final AcompanharJobService acompanhar = mock(AcompanharJobService.class); + + private final ConfirmarParametrosService confirmar = mock(ConfirmarParametrosService.class); + + private final ExecutarAcaoService executar = mock(ExecutarAcaoService.class); + + private final ReprocessarJobService reprocessar = mock(ReprocessarJobService.class); + + private final EmissoresSse emissores = mock(EmissoresSse.class); + + private MockMvc mvc; + + @BeforeEach + void preparar() { + when(this.jdbc.queryForList(contains("FROM usuarios"), eq(UUID.class), eq("subject-a"), eq("login-a"))) + .thenReturn(List.of(USUARIO)); + when(this.jdbc.update(contains("UPDATE usuarios"), any(), any(), any(), eq(USUARIO))).thenReturn(1); + when(this.jdbc.queryForList(contains("FROM jobs"), eq(UUID.class), eq(JOB))).thenReturn(List.of(USUARIO)); + UsuarioAtual usuario = new UsuarioAtual(this.jdbc, mock(AppProperties.class)); + this.mvc = MockMvcBuilders + .standaloneSetup(new CriarJobController(this.criar), new ListarJobsController(this.listar), + new BuscarJobController(this.buscar), + new AcompanharJobController(this.acompanhar, new CorrelationContext()), + new ConfirmarParametrosController(this.confirmar), + new ExecutarAcaoController(this.executar, this.emissores), + new ReprocessarJobController(this.reprocessar), new RotaSemPolitica()) + .addInterceptors(new AutorizacaoJobsInterceptor(usuario, new AutorizadorDeJob(this.jdbc))) + .setControllerAdvice(new AutorizacaoDeJobAdvice(), new BuscarJobAdvice(), new AcompanharJobAdvice(), + new ConfirmarParametrosAdvice(), new ReprocessarJobAdvice(), new CriarJobAdvice(), + new ExecutarAcaoAdvice()) + .build(); + } + + @AfterEach + void limparSessao() { + SecurityContextHolder.clearContext(); + } + + @Test + void preflightCorsNaoResolveUsuarioNemAplicaPoliticaDeNegocio() { + UsuarioAtual usuario = mock(UsuarioAtual.class); + AutorizadorDeJob autorizador = mock(AutorizadorDeJob.class); + AutorizacaoJobsInterceptor interceptor = new AutorizacaoJobsInterceptor(usuario, autorizador); + MockHttpServletRequest pedido = new MockHttpServletRequest("OPTIONS", "/jobs"); + pedido.addHeader("Origin", "https://app.exemplo.com"); + pedido.addHeader("Access-Control-Request-Method", "POST"); + + assertThat(interceptor.preHandle(pedido, new MockHttpServletResponse(), new Object())).isTrue(); + verifyNoInteractions(usuario, autorizador); + } + + @ParameterizedTest + @MethodSource("requisicoesSemPreflight") + void somentePreflightRealIgnoraPoliticaDeNegocio(String metodo, boolean origem, boolean metodoCors) { + UsuarioAtual usuario = mock(UsuarioAtual.class); + AutorizadorDeJob autorizador = mock(AutorizadorDeJob.class); + AutorizacaoJobsInterceptor interceptor = new AutorizacaoJobsInterceptor(usuario, autorizador); + MockHttpServletRequest pedido = new MockHttpServletRequest(metodo, "/jobs"); + if (origem) { + pedido.addHeader("Origin", "https://app.exemplo.com"); + } + if (metodoCors) { + pedido.addHeader("Access-Control-Request-Method", "POST"); + } + + assertThatThrownBy(() -> interceptor.preHandle(pedido, new MockHttpServletResponse(), new Object())) + .isInstanceOf(SemPermissaoNoJobException.class); + verifyNoInteractions(usuario, autorizador); + } + + static Stream requisicoesSemPreflight() { + return Stream.of(Arguments.of("OPTIONS", false, false), Arguments.of("OPTIONS", true, false), + Arguments.of("OPTIONS", false, true), Arguments.of("GET", true, true)); + } + + static Stream matriz() { + return Stream + .of(List.of("profissional-rh"), List.of("auditor"), List.of(), + List.of("profissional-rh", "auditor"), List.of("offline_access", "profissional-rh"), + List.of("default-roles-synapse", "auditor")) + .flatMap(papeis -> rotas() + .flatMap(rota -> Stream.of(true, false).map(dono -> Arguments.of(rota, papeis, dono)))); + } + + static Stream rotas() { + return Stream.of("criar", "listar", "buscar", "events", "parameters", "reprocessar", "confirmar_liberar", + "cancelar", "salvar", "arquivar"); + } + + @ParameterizedTest(name = "{0} papeis={1} dono={2}") + @MethodSource("matriz") + void aplicaMatrizAntesDeExecutarController(String rota, List papeis, boolean dono) throws Exception { + autenticar(papeis); + prepararRespostas(); + when(this.jdbc.queryForList(contains("FROM jobs"), eq(UUID.class), eq(JOB))) + .thenReturn(List.of(dono ? USUARIO : UUID.randomUUID())); + boolean permitido = papeis.contains("profissional-rh") && !papeis.contains("auditor") + && (dono || rota.equals("criar") || rota.equals("listar")); + if (permitido) { + this.mvc.perform(pedido(rota)) + .andExpect(status().is(rota.equals("parameters") ? 202 + : rota.equals("criar") || rota.equals("reprocessar") ? 201 : 200)); + verificarServico(rota); + } + else { + this.mvc.perform(pedido(rota)) + .andExpect(status().isForbidden()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.codigo").value("sem_permissao")) + .andExpect(jsonPath("$.mensagem").value("Você não tem permissão para esta ação.")); + verifyNoInteractions(this.criar, this.listar, this.buscar, this.acompanhar, this.confirmar, this.executar, + this.reprocessar, this.emissores); + if (papeis.isEmpty() || papeis.containsAll(List.of("profissional-rh", "auditor"))) { + verifyNoInteractions(this.jdbc); + } + } + } + + @Test + void rotaSemPoliticaERecusadaMesmoComRhAutenticado() throws Exception { + autenticar(List.of("profissional-rh")); + this.mvc.perform(get("/jobs/sem-politica")).andExpect(status().isForbidden()); + verifyNoInteractions(this.jdbc); + } + + @Test + void inexistentePreserva404() throws Exception { + autenticar(List.of("profissional-rh")); + when(this.jdbc.queryForList(contains("FROM jobs"), eq(UUID.class), eq(JOB))).thenReturn(List.of()); + this.mvc.perform(get("/jobs/{id}", JOB)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.codigo").value("job_nao_encontrado")); + verifyNoInteractions(this.buscar); + } + + @Test + void reconexaoSseConsultaPosseNovamenteAntesDeAbrirEmissor() throws Exception { + autenticar(List.of("profissional-rh")); + when(this.acompanhar.acompanhar(JOB)).thenReturn(new SseEmitter()); + this.mvc.perform(pedido("events")).andExpect(status().isOk()).andExpect(request().asyncStarted()); + when(this.jdbc.queryForList(contains("FROM jobs"), eq(UUID.class), eq(JOB))) + .thenReturn(List.of(UUID.randomUUID())); + this.mvc.perform(pedido("events").header("Last-Event-ID", "1")).andExpect(status().isForbidden()); + verify(this.acompanhar, times(1)).acompanhar(JOB); + verify(this.jdbc, times(2)).queryForList(contains("FROM jobs"), eq(UUID.class), eq(JOB)); + } + + @Test + void identidadeNoBodyQueryOuHeaderNaoEscolheDono() throws Exception { + autenticar(List.of("profissional-rh")); + prepararRespostas(); + String outro = UUID.randomUUID().toString(); + this.mvc + .perform(pedido("criar") + .content(CriarJobControllerTests.FORMULARIO.replace("\"origem\"", + "\"usuario_id\":\"" + outro + "\",\"origem\"")) + .param("usuario_id", outro) + .header("X-Usuario-Id", outro)) + .andExpect(status().isCreated()); + verify(this.criar).criar(any(), eq(USUARIO)); + } + + @ParameterizedTest + @MethodSource("rotas") + void negacaoPrecedeInterpretacaoDoCorpo(String rota) throws Exception { + autenticar(List.of("auditor")); + this.mvc.perform(pedido(rota).content("{")).andExpect(status().isForbidden()); + verifyNoInteractions(this.criar, this.listar, this.buscar, this.acompanhar, this.confirmar, this.executar, + this.reprocessar); + } + + private void prepararRespostas() { + RegraCriadaDto regra = new RegraCriadaDto(UUID.randomUUID(), 1, "confirmacao_usuario", + CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO).representacao(), Instant.now()); + JobCriadoDto criado = new JobCriadoDto(JOB, "gerando_regra", "formulario", List.of("2025-11"), BigDecimal.TEN, + Instant.now(), UUID.randomUUID(), null, regra); + JobDetalhadoDto detalhe = new JobDetalhadoDto(JOB, "gerando_regra", "formulario", List.of("2025-11"), + BigDecimal.TEN, Instant.now(), null, null, UUID.randomUUID(), null, List.of(regra), null); + when(this.criar.criar(any(), any())).thenReturn(criado); + when(this.listar.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 0)); + when(this.buscar.buscar(JOB)).thenReturn(detalhe); + when(this.acompanhar.acompanhar(JOB)).thenReturn(new SseEmitter()); + when(this.confirmar.confirmar(eq(JOB), any())).thenReturn(criado); + when(this.reprocessar.reprocessar(eq(JOB), any())).thenReturn(criado); + when(this.executar.aplicar(eq(JOB), any())).thenReturn(new ExecutarAcaoService.AcaoAplicada( + EventoEstadoDto.transicao(JOB, JobStatus.AGUARDANDO_DECISAO_USUARIO, JobStatus.LIBERADO, null), + detalhe)); + } + + private void verificarServico(String rota) { + switch (rota) { + case "criar" -> verify(this.criar).criar(any(), eq(USUARIO)); + case "listar" -> verify(this.listar).listar(any(), argThat(acesso -> acesso.usuarioId().equals(USUARIO))); + case "buscar" -> verify(this.buscar).buscar(JOB); + case "events" -> verify(this.acompanhar).acompanhar(JOB); + case "parameters" -> verify(this.confirmar).confirmar(eq(JOB), any()); + case "reprocessar" -> verify(this.reprocessar).reprocessar(eq(JOB), any()); + default -> verify(this.executar).aplicar(JOB, AcaoJob.deColuna(rota)); + } + } + + static MockHttpServletRequestBuilder pedido(String rota) { + return switch (rota) { + case "criar" -> + post("/jobs").contentType(MediaType.APPLICATION_JSON).content(CriarJobControllerTests.FORMULARIO); + case "listar" -> get("/jobs"); + case "buscar" -> get("/jobs/{id}", JOB); + case "events" -> get("/jobs/{id}/events", JOB).accept(MediaType.TEXT_EVENT_STREAM); + case "parameters" -> post("/jobs/{id}/parameters", JOB).contentType(MediaType.APPLICATION_JSON) + .content(ConfirmarParametrosControllerTests.CONFIRMAR); + case "reprocessar" -> post("/jobs/{id}/reprocessar", JOB); + default -> post("/jobs/{id}/actions", JOB).contentType(MediaType.APPLICATION_JSON) + .content("{\"acao\":\"" + rota + "\"}"); + }; + } + + private static void autenticar(List papeis) { + Jwt token = Jwt.withTokenValue("teste") + .header("alg", "RS256") + .subject("subject-a") + .claim("preferred_username", "login-a") + .claim("realm_access", Map.of("roles", papeis)) + .build(); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(token)); + } + + @RestController + static class RotaSemPolitica { + + @GetMapping("/jobs/sem-politica") + String semPolitica() { + throw new AssertionError("Rota sem política foi executada."); + } + + } + +} diff --git a/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java index 1093c21..88afd80 100644 --- a/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java @@ -33,6 +33,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.PapelDoUsuario; import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; @@ -97,9 +98,10 @@ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) contexto.refresh(); service = contexto.getBean(BuscarJobService.class); UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); - when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); - mvc = MockMvcBuilders - .standaloneSetup(new BuscarJobController(service, contexto.getBean(AutorizadorDeJob.class), usuarioAtual)) + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)); + mvc = MockMvcBuilders.standaloneSetup(new BuscarJobController(service)) + .addInterceptors(new AutorizacaoJobsInterceptor(usuarioAtual, new AutorizadorDeJob(jdbc))) + .setControllerAdvice(new BuscarJobAdvice(), new AutorizacaoDeJobAdvice()) .build(); } diff --git a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java index 28c38fd..0003867 100644 --- a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java +++ b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java @@ -19,6 +19,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.PapelDoUsuario; import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; @@ -48,8 +49,10 @@ class CriarJobControllerTests { @BeforeEach void preparar() { - when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); - this.mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(this.service, this.usuarioAtual)) + when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)); + this.mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(this.service)) + .addInterceptors(new AutorizacaoJobsInterceptor(this.usuarioAtual, + new AutorizadorDeJob(mock(org.springframework.jdbc.core.JdbcTemplate.class)))) .setControllerAdvice(new CriarJobAdvice()) .build(); } diff --git a/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java index bc54e6c..ba7e5c2 100644 --- a/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java @@ -39,6 +39,7 @@ import synapse.api.core.outbox.Outbox; import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.PapelDoUsuario; import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; @@ -133,8 +134,9 @@ void httpSemPrincipalPersisteUsuarioAtivoIgnorandoUserIdDoBody() throws Exceptio .replace("\"texto_livre\":null", "\"texto_livre\":\"Observação recebida\",\"extra\":{\"preservar\":true}") .replace("0.025", "0.025000000000000000001"); UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); - when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); - var mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(service, usuarioAtual)) + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)); + var mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(service)) + .addInterceptors(new AutorizacaoJobsInterceptor(usuarioAtual, new AutorizadorDeJob(jdbc))) .setControllerAdvice(contexto.getBean(CriarJobAdvice.class)) .build(); var resposta = mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(corpo)) diff --git a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java index ec79e54..7cca189 100644 --- a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java +++ b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java @@ -19,8 +19,6 @@ import synapse.api.core.sse.EmissoresSse; import synapse.api.core.sse.EventoSse; -import synapse.api.core.security.AcessoDoUsuario; -import synapse.api.core.security.UsuarioAtual; import synapse.api.job.ExecutarAcaoService.AcaoAplicada; import static org.mockito.ArgumentMatchers.any; @@ -43,18 +41,11 @@ class ExecutarAcaoControllerTests { private final EmissoresSse emissores = mock(EmissoresSse.class); - private final AutorizadorDeJob autorizador = mock(AutorizadorDeJob.class); - - private final UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); - private MockMvc mvc; @BeforeEach void preparar() { - when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(UUID.randomUUID(), false)); - this.mvc = MockMvcBuilders - .standaloneSetup( - new ExecutarAcaoController(this.service, this.emissores, this.autorizador, this.usuarioAtual)) + this.mvc = MockMvcBuilders.standaloneSetup(new ExecutarAcaoController(this.service, this.emissores)) .setControllerAdvice(new ExecutarAcaoAdvice()) .build(); } diff --git a/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java b/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java index 1a5656e..4f9606e 100644 --- a/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java +++ b/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java @@ -16,6 +16,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.PapelDoUsuario; import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; @@ -38,8 +39,11 @@ class ListarJobsControllerTests { @BeforeEach void preparar() { - when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(UUID.randomUUID(), false)); - this.mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(this.service, this.usuarioAtual)) + when(this.usuarioAtual.obter()) + .thenReturn(new AcessoDoUsuario(UUID.randomUUID(), PapelDoUsuario.PROFISSIONAL_RH)); + this.mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(this.service)) + .addInterceptors(new AutorizacaoJobsInterceptor(this.usuarioAtual, + new AutorizadorDeJob(mock(org.springframework.jdbc.core.JdbcTemplate.class)))) .setControllerAdvice(new ListarJobsAdvice()) .build(); } diff --git a/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java b/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java index 7cc049f..a403d55 100644 --- a/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java @@ -39,6 +39,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.PapelDoUsuario; import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; @@ -104,8 +105,9 @@ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) contexto.refresh(); service = contexto.getBean(ListarJobsService.class); UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); - when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); - mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(service, usuarioAtual)) + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)); + mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(service)) + .addInterceptors(new AutorizacaoJobsInterceptor(usuarioAtual, new AutorizadorDeJob(jdbc))) .setControllerAdvice(contexto.getBean(ListarJobsAdvice.class)) .build(); } @@ -132,9 +134,25 @@ static class Config { } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(PapelDoUsuario.class) + void consultaSempreFiltraDonoNaPaginaENoTotalMesmoComPapelAuditor(PapelDoUsuario papel) { + UUID proprio = criarJob("2026-01-01T10:00:00Z"); + UUID alheio = criarJob("2026-01-02T10:00:00Z"); + UUID outro = UUID.randomUUID(); + jdbc.update( + "INSERT INTO usuarios (id, login, nome, papel, criado_em) VALUES (?, ?, 'Outro', 'profissional_rh', now())", + outro, outro.toString()); + jdbc.update("UPDATE jobs SET usuario_id = ? WHERE id = ?", outro, alheio); + PaginaJobsDto pagina = service.listar(new ListarJobsRequisicao(0, 20), new AcessoDoUsuario(USUARIO, papel)); + assertThat(pagina.total()).isEqualTo(1); + assertThat(pagina.itens()).extracting(JobResumoDto::id).containsExactly(proprio); + } + @Test void devolveVazioComTotalZeroQuandoNaoHaJob() { - PaginaJobsDto pagina = service.listar(new ListarJobsRequisicao(0, 20)); + PaginaJobsDto pagina = service.listar(new ListarJobsRequisicao(0, 20), + new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)); assertThat(pagina.itens()).isEmpty(); assertThat(pagina.total()).isZero(); @@ -147,7 +165,8 @@ void percorrerTodasAsPaginasCobreCadaJobUmaVezSemRepetirNemOmitir() { List vistos = new java.util.ArrayList<>(); for (int pagina = 0; pagina < 3; pagina++) { - PaginaJobsDto resultado = service.listar(new ListarJobsRequisicao(pagina, 2)); + PaginaJobsDto resultado = service.listar(new ListarJobsRequisicao(pagina, 2), + new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)); resultado.itens().forEach(item -> vistos.add(item.id())); } @@ -160,12 +179,14 @@ void ordenaPorCriadoEmDecrescenteEEDesempataDeFormaEstavelEntreChamadas() { UUID b = criarJob("2026-01-01T10:00:00Z"); UUID c = criarJob("2026-01-02T10:00:00Z"); - List primeiraChamada = service.listar(new ListarJobsRequisicao(0, 20)) + List primeiraChamada = service + .listar(new ListarJobsRequisicao(0, 20), new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)) .itens() .stream() .map(JobResumoDto::id) .toList(); - List segundaChamada = service.listar(new ListarJobsRequisicao(0, 20)) + List segundaChamada = service + .listar(new ListarJobsRequisicao(0, 20), new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)) .itens() .stream() .map(JobResumoDto::id) @@ -183,7 +204,8 @@ void ordenaPorCriadoEmDecrescenteEEDesempataDeFormaEstavelEntreChamadas() { void umaPaginaAlemDoFimDevolveItensVazioComOTotalCorreto() { criarJob("2026-01-01T10:00:00Z"); - PaginaJobsDto pagina = service.listar(new ListarJobsRequisicao(5, 20)); + PaginaJobsDto pagina = service.listar(new ListarJobsRequisicao(5, 20), + new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)); assertThat(pagina.itens()).isEmpty(); assertThat(pagina.total()).isEqualTo(1); @@ -248,7 +270,8 @@ void jobReprocessadoTrazJobOrigemId() { UUID original = criarJob("2026-01-01T10:00:00Z"); UUID reprocessado = criarJobReprocessado("2026-01-02T10:00:00Z", original); - PaginaJobsDto pagina = service.listar(new ListarJobsRequisicao(0, 20)); + PaginaJobsDto pagina = service.listar(new ListarJobsRequisicao(0, 20), + new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)); JobResumoDto item = pagina.itens().stream().filter(i -> i.id().equals(reprocessado)).findFirst().orElseThrow(); assertThat(item.job_origem_id()).isEqualTo(original); @@ -277,7 +300,9 @@ void httpDeVoltaAoNavegadorOmiteAusentesETrazPresentes() throws Exception { } private JobResumoDto itemUnico() { - List itens = service.listar(new ListarJobsRequisicao(0, 20)).itens(); + List itens = service + .listar(new ListarJobsRequisicao(0, 20), new AcessoDoUsuario(USUARIO, PapelDoUsuario.PROFISSIONAL_RH)) + .itens(); assertThat(itens).hasSize(1); return itens.getFirst(); } diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java index 56d491a..25d3b26 100644 --- a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java @@ -45,6 +45,7 @@ import synapse.api.core.outbox.Outbox; import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.PapelDoUsuario; import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; @@ -120,12 +121,12 @@ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) contexto.refresh(); service = contexto.getBean(ReprocessarJobService.class); UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); - when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(DONO_ORIGINAL, false)); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(DONO_ORIGINAL, PapelDoUsuario.PROFISSIONAL_RH)); mvc = MockMvcBuilders .standaloneSetup(contexto.getBean(ReprocessarJobController.class), - new BuscarJobController(contexto.getBean(BuscarJobService.class), - contexto.getBean(AutorizadorDeJob.class), usuarioAtual), + new BuscarJobController(contexto.getBean(BuscarJobService.class)), contexto.getBean(ConfirmarParametrosController.class)) + .addInterceptors(new AutorizacaoJobsInterceptor(usuarioAtual, new AutorizadorDeJob(jdbc))) .setControllerAdvice(new ReprocessarJobAdvice(), new ConfirmarParametrosAdvice()) .build(); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 72e8606..1d2dd0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -363,7 +363,7 @@ Como o pipeline trata a entrada em geração de código como a fronteira entre a - **Persistência (Spring Data JPA)** - repositórios da tabela `job` e das tabelas relacionadas (ver seção 5), reaproveitados pelas fatias que precisam. Inclui os artefatos que a própria API produz: áudio (`bytea`) e transcrição, em tabela separada das de consulta frequente para não pesar o dia a dia. - **Dono das migrations** - o schema é único e a API é quem o versiona, via Liquibase (seção 5), inclusive as tabelas que Worker e codegen escrevem. Eles inserem; não criam nem alteram estrutura. - **Autenticação** - O [ADR-006](adrs/ADR-006.md) adota o Keycloak como provedor OIDC, responsável pelas credenciais e pela sessão. Com autenticação habilitada, a API atua como resource server do Spring Security: valida assinatura, emissor e validade do JWT recebido como Bearer nas chamadas REST e SSE. A API mantém o usuário local e seu vínculo com jobs pelo claim `sub`, persistido em `usuarios.keycloak_sub`. -- **Autorização** - A [DEC-087](decisoes/dec-087.md) mantém os papéis profissional de RH e auditor, com o auditor em função investigativa. Existem checagens de posse em controllers, mas a cobertura uniforme de todas as operações, as permissões de mutação e o tratamento de acesso negado ainda pertencem à tarefa específica do middleware de autorização. A adoção do Keycloak não conclui essa tarefa. +- **Autorização** - A matriz HTTP da [DEC-087](decisoes/dec-087.md) exige profissional de RH nas rotas operacionais de jobs; o auditor permanece investigativo e só terá acesso entre usuários por operações de auditoria explicitamente autorizadas, fora da T-091. Um interceptor exige a declaração da operação e aplica a política central antes de executar o controller, interpretar o corpo ou abrir SSE. Rotas de jobs sem política são negadas. O papel é validado antes de criar ou atualizar a conta local; a posse vem de `jobs.usuario_id`. Cada reconexão SSE repete a autorização. Sessão inválida recebe 401; papel ou posse sem permissão recebe `403 sem_permissao`. **Pontos de atenção para o desenvolvimento:** diff --git a/docs/adrs/ADR-006.md b/docs/adrs/ADR-006.md index 7f2d9e9..06e9589 100644 --- a/docs/adrs/ADR-006.md +++ b/docs/adrs/ADR-006.md @@ -29,16 +29,25 @@ formulário; uma resposta 401 reinicia o login. ## Autorização A [DEC-087](../decisoes/dec-087.md) continua definindo as permissões por papel. -A cobertura uniforme da posse dos jobs, das operações permitidas ao auditor e -das respostas de acesso negado será concluída na tarefa específica do middleware -de autorização. As checagens existentes nos controllers não encerram essa tarefa. +A matriz HTTP vigente está registrada na DEC-087 (T-091). As rotas operacionais de +jobs exigem `profissional-rh` e, quando recebem um job, sua posse persistida. +O auditor é não operacional: acesso a jobs de terceiros só será permitido por +operações de auditoria, trilha ou explicação explicitamente autorizadas, que não +são implementadas na T-091. Não há bypass geral para o auditor. + +A API resolve primeiro o papel em `realm_access.roles`: nenhum dos dois papéis de +negócio conhecidos, ou ambos simultaneamente, resulta em `403 sem_permissao`, sem +escrita na conta local. Papéis técnicos adicionais são ignorados. O papel do JWT +é a autoridade em produção; a coluna local não o substitui. Novas operações de +jobs sem política explícita são negadas antes da execução do controller. ## Consequências - Login, logout e expiração de sessão seguem o fluxo OIDC, sem `POST /auth/login` proprietário na API. - Tokens não são persistidos no navegador. -- Usuários autenticados pela primeira vez ganham uma conta local de papel - `profissional_rh`; o papel `auditor` do realm pode consultar jobs de terceiros. +- Usuários autenticados com exatamente um papel conhecido ganham uma conta local + com o papel correspondente: `profissional-rh` → `profissional_rh` ou `auditor` + → `auditor`. Contas existentes não têm o papel local sincronizado no login. - O Keycloak passa a ser uma dependência operacional da API nos ambientes com autenticação habilitada. diff --git a/docs/database/modelo-dados.md b/docs/database/modelo-dados.md index 5a5234e..793d05d 100644 --- a/docs/database/modelo-dados.md +++ b/docs/database/modelo-dados.md @@ -18,9 +18,9 @@ São catorze, num schema único. Cada uma aparece abaixo com o que guarda e, qua A identidade é autenticada pelo Keycloak, conforme o [ADR-006](../adrs/ADR-006.md). A API mantém esta conta local para relacionar o usuário às submissões, aos jobs e à trilha de auditoria. `keycloak_sub` armazena o claim `sub` e tem unicidade; `senha_hash` é um campo legado, opcional, preenchido com `NULL` nas novas contas do Keycloak. -Ao resolver a identidade autenticada, a API busca o `sub` ou um registro legado com `keycloak_sub` nulo e `login` igual ao `preferred_username`. Se não encontrar uma conta, cria uma com papel local `profissional_rh`. O indicador de auditor é lido de `realm_access.roles` no token; esse processo não sincroniza o papel do realm com a coluna `papel`. +Antes de consultar ou escrever a conta, a API exige exatamente um papel de negócio conhecido em `realm_access.roles`: `profissional-rh` ou `auditor`. Nenhum deles, ou ambos, resulta em 403; papéis técnicos adicionais são ignorados. A API busca o `sub`, com precedência sobre um registro legado com `keycloak_sub` nulo e `login` igual ao `preferred_username`. Se não encontrar uma conta, cria uma com papel local correspondente (`profissional_rh` ou `auditor`). O papel do token rege a autorização em produção; contas existentes não têm a coluna `papel` sincronizada no login. No modo de desenvolvimento sem Keycloak, a API usa o primeiro usuário ativo e lê seu papel local canônico. -Usuário desativado mantém a linha porque jobs antigos a referenciam. `ativo = false` impede a resolução dessa conta local pela API, mas não encerra a sessão no Keycloak. A cobertura uniforme das operações depende do middleware de autorização descrito no ADR-006. +Usuário desativado mantém a linha porque jobs antigos a referenciam. `ativo = false` impede a resolução dessa conta local pela API, mas não encerra a sessão no Keycloak. As operações de jobs seguem a matriz da DEC-087 e conferem a posse por `jobs.usuario_id`, conforme o ADR-006. ### `submissoes` diff --git a/docs/decisoes/dec-087.md b/docs/decisoes/dec-087.md index 44b1c7d..d835bcf 100644 --- a/docs/decisoes/dec-087.md +++ b/docs/decisoes/dec-087.md @@ -34,6 +34,32 @@ Apenas os seguintes papéis existirão na configuração do sistema (API, Fronte --- +## Matriz HTTP vigente (T-091) + +O auditor é não operacional. As rotas atuais de jobs pertencem ao profissional +de RH e usam exclusivamente a identidade autenticada (`sub` → `usuarios.keycloak_sub` +→ `usuarios.id`) e a posse persistida em `jobs.usuario_id`. + +| Operação | `profissional-rh` | `auditor` | Posse | +| --- | --- | --- | --- | +| `POST /jobs` | Permitido | Negado | Cria para si | +| `GET /jobs` | Permitido | Negado | Somente próprios | +| `GET /jobs/{id}` | Permitido | Negado | Somente próprio | +| `GET /jobs/{id}/events` | Permitido | Negado | Somente próprio, inclusive na reconexão | +| `POST /jobs/{id}/parameters` | Permitido | Negado | Somente próprio | +| `POST /jobs/{id}/actions` | Permitido | Negado | Somente próprio, para todas as ações | +| `POST /jobs/{id}/reprocessar` | Permitido | Negado | Somente dono do job original arquivado | + +O acesso do auditor a jobs de terceiros só ocorre por operações de auditoria, +trilha ou explicação explicitamente autorizadas. Essas operações não são +implementadas pela T-091; o papel não concede bypass de posse nas rotas acima. + +Exatamente um papel de negócio conhecido deve estar presente em `realm_access.roles`. +Token sem `profissional-rh` nem `auditor`, ou com ambos, recebe `403 sem_permissao` +antes de criar ou atualizar a conta local. Papéis técnicos/default adicionais do +Keycloak são ignorados nessa decisão. Sessão ausente, inválida ou expirada recebe +401. Operação sem política explícita é negada por padrão. + ## Mapa História × Papel (Sprint 1) | História (US) | Descrição Resumida | Papel Autorizado |